feat: 优化 session 管理

This commit is contained in:
chaoszhu
2026-08-29 20:07:06 +08:00
parent 932fb708c4
commit cf8a5eae50
11 changed files with 166 additions and 60 deletions
+58 -16
View File
@@ -22,7 +22,7 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
late List<String> _whitelist;
bool _whitelistDirty = false;
bool _whitelistSaving = false;
bool _purging = false;
bool _revokingAll = false;
String? _revokingId;
@override
@@ -89,37 +89,40 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
}
}
Future<void> _purgeOld() async {
Future<void> _revokeAll() async {
final l = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(l.tr('sessions.purgeConfirmTitle')),
content: Text(l.tr('sessions.purgeConfirmBody')),
title: Text(l.tr('sessions.revokeAllConfirmTitle')),
content: Text(l.tr('sessions.revokeAllConfirmBody')),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(l.tr('common.cancel')),
),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: ctx.colors.warning,
foregroundColor: ctx.colors.fontOnPrimary,
),
onPressed: () => Navigator.of(ctx).pop(true),
child: Text(l.tr('common.continue')),
child: Text(l.tr('sessions.revokeAll')),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _purging = true);
setState(() => _revokingAll = true);
try {
await ref.read(settingsRepositoryProvider).purgeOldSessions();
await ref.read(settingsRepositoryProvider).revokeAllSessions();
if (!mounted) return;
_showSnack(l.tr('sessions.purgeDone'));
await ref.read(loginLogProvider.notifier).refresh();
await ref.read(authProvider.notifier).signOut();
} on ApiFailure catch (err) {
if (!mounted) return;
_showSnack(err.message);
} finally {
if (mounted) setState(() => _purging = false);
if (mounted) setState(() => _revokingAll = false);
}
}
@@ -195,19 +198,22 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
scrolledUnderElevation: 0,
title: Text(l.tr('settings.sessions.title')),
actions: [
IconButton(
tooltip: l.tr('sessions.purgeTooltip'),
onPressed: _purging || logAsync.isLoading ? null : _purgeOld,
icon: _purging
TextButton.icon(
onPressed: _revokingAll || logAsync.isLoading ? null : _revokeAll,
style: TextButton.styleFrom(
foregroundColor: context.colors.warning,
),
icon: _revokingAll
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: context.colors.danger,
color: context.colors.warning,
),
)
: Icon(Icons.delete_outline, color: context.colors.danger),
: Icon(Icons.logout_rounded, color: context.colors.warning),
label: Text(l.tr('sessions.revokeAll')),
),
const SizedBox(width: 6),
],
@@ -252,6 +258,8 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
label: l.tr('sessions.loginRecordsTitle'),
count: data.sessions.length,
),
_RetentionTip(label: l.tr('sessions.retentionTip')),
const SizedBox(height: 10),
if (data.sessions.isEmpty)
_EmptyState(label: l.tr('sessions.empty'))
else
@@ -274,6 +282,40 @@ class _SessionsPageState extends ConsumerState<SessionsPage> {
}
}
class _RetentionTip extends StatelessWidget {
const _RetentionTip({required this.label});
final String label;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: context.colors.accentSoft,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: context.colors.strongBorder),
),
child: Row(
children: [
Icon(Icons.info_outline, size: 17, color: context.colors.primary),
const SizedBox(width: 8),
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: 12,
height: 1.4,
color: context.colors.muted,
),
),
),
],
),
);
}
}
class _IpWhitelistCard extends StatelessWidget {
const _IpWhitelistCard({
required this.whitelist,
@@ -112,8 +112,8 @@ class SettingsRepository {
await apiClient.deleteJson('/revoke-login/$idOrDeviceId');
}
Future<void> purgeOldSessions() async {
await apiClient.deleteJson('/remove-some-login-records');
Future<void> revokeAllSessions() async {
await apiClient.deleteJson('/revoke-all-sessions');
}
// ---- Proxies CRUD ----
+6 -5
View File
@@ -376,11 +376,12 @@ const Map<String, String> stringsEn = {
'sessions.ipEmpty': 'No IPs configured — access is unrestricted',
'sessions.ipSave': 'Save whitelist',
'sessions.loginRecordsTitle': 'Login records',
'sessions.purgeTooltip': 'Delete records older than 7 days',
'sessions.purgeConfirmTitle': 'Purge old records?',
'sessions.purgeConfirmBody':
'All login records older than 7 days will be deleted.',
'sessions.purgeDone': 'Old records cleared',
'sessions.retentionTip':
'Login records are retained for 90 days and then deleted automatically.',
'sessions.revokeAll': 'Log out all',
'sessions.revokeAllConfirmTitle': 'Log out all sessions?',
'sessions.revokeAllConfirmBody':
'Every device, including this one, will be signed out immediately.',
'sessions.current': 'Current session',
'sessions.native': 'Native',
'sessions.revoked': 'Revoked',
+4 -4
View File
@@ -356,10 +356,10 @@ const Map<String, String> stringsZh = {
'sessions.ipEmpty': '尚未设置任何 IP,默认不限制访问',
'sessions.ipSave': '保存白名单',
'sessions.loginRecordsTitle': '登录记录',
'sessions.purgeTooltip': '清理 7 天前的记录',
'sessions.purgeConfirmTitle': '清理历史记录?',
'sessions.purgeConfirmBody': '将删除 7 天前的所有登录记录,确定继续?',
'sessions.purgeDone': '已清理过期记录',
'sessions.retentionTip': '登录记录默认保留 90 天,超过 90 天的记录将自动清理',
'sessions.revokeAll': '注销所有会话',
'sessions.revokeAllConfirmTitle': '注销所有会话?',
'sessions.revokeAllConfirmBody': '所有设备(包括当前设备)都将立即退出登录。',
'sessions.current': '当前会话',
'sessions.native': '本地端',
'sessions.revoked': '已撤销',
+21 -8
View File
@@ -1,8 +1,12 @@
import { KeyDB, SessionDB } from '../utils/db-class.js'
import { cookieSecure } from '../config/index.js'
import { disconnectAllSessionConnections, revokeAllSessions } from '../utils/auth-session.js'
import { pruneLoginLogs } from '../utils/login-log.js'
const keyDB = new KeyDB().getInstance()
const sessionDB = new SessionDB().getInstance()
async function getLog({ res }) {
await pruneLoginLogs(sessionDB)
let sessionList = await sessionDB.findAsync({})
let { ipWhiteList = [] } = await keyDB.findOneAsync({}) || {}
ipWhiteList = ipWhiteList.filter(ip => typeof ip === 'string' && ip.trim() !== '')
@@ -24,13 +28,6 @@ const saveIpWhiteList = async ({ res, request }) => {
res.success({ msg: 'success' })
}
const removeSomeLoginRecords = async ({ res }) => {
const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000
const result = await sessionDB.removeAsync({ create: { $lt: sevenDaysAgo } }, { multi: true })
if (result === 0) return res.success({ msg: '没有符合条件的登录日志' })
res.success({ msg: `已成功移除 ${ result } 条登录日志` })
}
const revokeLoginSid = async (ctx) => {
const { res, request, cookies } = ctx
let { params: { id } } = request
@@ -50,9 +47,25 @@ const revokeLoginSid = async (ctx) => {
res.success({ msg: '注销凭证成功' })
}
const revokeAllLoginSessions = async (ctx) => {
try {
await revokeAllSessions(sessionDB)
} finally {
// 已建立的长连接不会再次经过鉴权,需要主动断开。
disconnectAllSessionConnections()
}
ctx.cookies.set('session', '', {
httpOnly: true,
expires: new Date(0),
sameSite: 'strict',
secure: cookieSecure
})
ctx.res.success({ msg: '已注销所有会话,请重新登录' })
}
export {
getLog,
saveIpWhiteList,
removeSomeLoginRecords,
revokeAllLoginSessions,
revokeLoginSid
}
+6 -6
View File
@@ -7,7 +7,7 @@ import { getGroupList, addGroupList, updateGroupList, removeGroup } from '../con
import { getScriptList, getLocalScriptList, addScript, updateScriptList, removeScript, batchRemoveScript, importScript } from '../controller/scripts.js'
import { getScriptGroupList, addScriptGroup, removeScriptGroup, updateScriptGroup } from '../controller/script-group.js'
import { getOnekeyRecord, removeOnekeyRecord } from '../controller/onekey.js'
import { getLog, saveIpWhiteList, removeSomeLoginRecords, revokeLoginSid } from '../controller/sessionLog.js'
import { getLog, saveIpWhiteList, revokeAllLoginSessions, revokeLoginSid } from '../controller/sessionLog.js'
import { getAIConfig, saveAIConfig, getAIModels, updateAIPreferences } from '../controller/chat.js'
import { getAgentSessions, getAgentSessionDetail, updateAgentSession, forkAgentSession, removeAgentSession, clearAgentSessions, editAgentSessionMessage } from '../controller/agent-session.js'
import { getAgentMcpServers, addAgentMcpServer, editAgentMcpServer, removeAgentMcpServer, testAgentMcpConnection, discoverAgentMcpServer } from '../controller/agent-mcp.js'
@@ -294,11 +294,11 @@ const log = [
path: '/ip-white-list',
controller: saveIpWhiteList
},
{
method: 'delete',
path: '/remove-some-login-records',
controller: removeSomeLoginRecords
},
{
method: 'delete',
path: '/revoke-all-sessions',
controller: revokeAllLoginSessions
},
{
method: 'delete',
path: '/revoke-login/:id',
+12 -4
View File
@@ -1,10 +1,12 @@
import schedule from 'node-schedule'
import { sendNoticeAsync } from '../utils/notify.js'
import { formatTimestamp } from '../utils/tools.js'
import { HostListDB } from '../utils/db-class.js'
import { HostListDB, SessionDB } from '../utils/db-class.js'
import { LOGIN_LOG_RETENTION_DAYS, pruneLoginLogs } from '../utils/login-log.js'
const hostListDB = new HostListDB().getInstance()
const sessionDB = new SessionDB().getInstance()
const expiredNotifyJob = async () => {
const expiredNotifyJob = async () => {
logger.info('=====开始检测服务器到期时间=====', new Date())
const hostList = await hostListDB.findAsync({})
for (const item of hostList) {
@@ -25,8 +27,14 @@ const expiredNotifyJob = async () => {
sendNoticeAsync('host_expired', title, temp + content)
}
}
}
}
const loginLogRetentionJob = async () => {
const removed = await pruneLoginLogs(sessionDB)
if (removed > 0) logger.info(`已清理 ${ removed } 条超过 ${ LOGIN_LOG_RETENTION_DAYS } 天的登录日志`)
}
export default () => {
schedule.scheduleJob('0 0 12 1/1 * ?', expiredNotifyJob)
schedule.scheduleJob('0 0 3 * * *', loginLogRetentionJob)
}
+15
View File
@@ -0,0 +1,15 @@
const LOGIN_LOG_RETENTION_DAYS = 90
const DAY_MS = 24 * 60 * 60 * 1000
const pruneLoginLogs = (sessionStore, now = Date.now()) => {
const cutoff = now - LOGIN_LOG_RETENTION_DAYS * DAY_MS
return sessionStore.removeAsync(
{ create: { $lt: cutoff } },
{ multi: true }
)
}
export {
LOGIN_LOG_RETENTION_DAYS,
pruneLoginLogs
}
+14
View File
@@ -6,6 +6,7 @@ import {
registerSocketServer,
revokeAllSessions
} from '../app/utils/auth-session.js'
import { LOGIN_LOG_RETENTION_DAYS, pruneLoginLogs } from '../app/utils/login-log.js'
const calls = []
const sessionStore = {
@@ -35,6 +36,19 @@ const storedSessions = await realSessionStore.findAsync({})
assert.equal(storedSessions.length, 3)
assert.ok(storedSessions.every(session => session.revoked === true))
const now = Date.UTC(2026, 7, 29)
const loginLogStore = new Datastore()
await loginLogStore.insertAsync([
{ session: 'old', create: now - (LOGIN_LOG_RETENTION_DAYS + 1) * 24 * 60 * 60 * 1000 },
{ session: 'boundary', create: now - LOGIN_LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000 },
{ session: 'recent', create: now - 10 * 24 * 60 * 60 * 1000 }
])
assert.equal(await pruneLoginLogs(loginLogStore, now), 1)
assert.deepEqual(
(await loginLogStore.findAsync({})).map(item => item.session).sort(),
['boundary', 'recent']
)
let disconnectCalls = 0
let destroyCalls = 0
const closeListeners = []
+2 -2
View File
@@ -228,8 +228,8 @@ export default {
skipErrorMessage: true
})
},
removeSomeLoginRecords() {
return axios({ url: '/remove-some-login-records', method: 'delete' })
revokeAllSessions() {
return axios({ url: '/revoke-all-sessions', method: 'delete' })
},
revokeLoginSid(id) {
return axios({ url: `/revoke-login/${ id }`, method: 'delete' })
+26 -13
View File
@@ -24,6 +24,14 @@
</template>
</el-alert>
<el-alert
class="retention_tip"
type="info"
title="登录记录默认保留 90 天,超过 90 天的记录将自动清理"
show-icon
:closable="false"
/>
<!-- table -->
<el-table v-loading="loading" :data="loginRecordList">
<el-table-column prop="ip" label="IP" />
@@ -71,12 +79,12 @@
<el-table-column label="操作" width="200">
<template #header>
<el-button
type="info"
type="warning"
size="small"
:loading="removeLogLoading"
@click="handleRemoveLogs"
:loading="revokeAllLoading"
@click="handleRevokeAllSessions"
>
移除一周前的登录日志
注销所有会话
</el-button>
</template>
<template #default="{ row }">
@@ -100,13 +108,13 @@ import { InfoFilled } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import dayjs from 'dayjs'
const { proxy: { $api, $message, $messageBox, $store } } = getCurrentInstance()
const { proxy: { $api, $message, $messageBox, $store, $router } } = getCurrentInstance()
const route = useRoute()
const loginRecordList = ref([])
const loading = ref(false)
const btnLoading = ref(false)
const removeLogLoading = ref(false)
const revokeAllLoading = ref(false)
const removeSidLoading = ref(false)
const total = ref('')
const allowedIPs = ref([])
@@ -152,23 +160,24 @@ const handleSaveAllowedIPs = async () => {
}
}
const handleRemoveLogs = async () => {
$messageBox.confirm('确定要移除一周前的登录日志吗?', '提示', {
const handleRevokeAllSessions = async () => {
$messageBox.confirm('确定要注销所有会话吗?所有设备(包括当前设备)都将立即退出登录。', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
.then(async () => {
removeLogLoading.value = true
revokeAllLoading.value = true
try {
const { msg } = await $api.removeSomeLoginRecords()
handleLookupLoginRecord()
const { msg } = await $api.revokeAllSessions()
$message.success(msg)
await $store.removeLoginInfo()
await $router.push('/login')
} catch (error) {
console.error(error)
$message.error('移除一周前的登录日志失败')
$message.error('注销所有会话失败')
} finally {
removeLogLoading.value = false
revokeAllLoading.value = false
}
})
}
@@ -199,4 +208,8 @@ onMounted(() => {
.allowed_ip_tag {
margin: 0 5px;
}
.retention_tip {
margin: 12px 0;
}
</style>