mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 13:38:14 +08:00
feat: 优化 session 管理
This commit is contained in:
@@ -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 ----
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '已撤销',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user