diff --git a/native/lib/features/settings/sessions_page.dart b/native/lib/features/settings/sessions_page.dart index f6df0e8..97623a0 100644 --- a/native/lib/features/settings/sessions_page.dart +++ b/native/lib/features/settings/sessions_page.dart @@ -22,7 +22,7 @@ class _SessionsPageState extends ConsumerState { late List _whitelist; bool _whitelistDirty = false; bool _whitelistSaving = false; - bool _purging = false; + bool _revokingAll = false; String? _revokingId; @override @@ -89,37 +89,40 @@ class _SessionsPageState extends ConsumerState { } } - Future _purgeOld() async { + Future _revokeAll() async { final l = AppLocalizations.of(context); final confirmed = await showDialog( 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 { 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 { 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 { } } +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, diff --git a/native/lib/features/settings/settings_repository.dart b/native/lib/features/settings/settings_repository.dart index fadc539..b5ec855 100644 --- a/native/lib/features/settings/settings_repository.dart +++ b/native/lib/features/settings/settings_repository.dart @@ -112,8 +112,8 @@ class SettingsRepository { await apiClient.deleteJson('/revoke-login/$idOrDeviceId'); } - Future purgeOldSessions() async { - await apiClient.deleteJson('/remove-some-login-records'); + Future revokeAllSessions() async { + await apiClient.deleteJson('/revoke-all-sessions'); } // ---- Proxies CRUD ---- diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index e1527a2..51b7cf5 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -376,11 +376,12 @@ const Map 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', diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index 5359231..59a1d3c 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -356,10 +356,10 @@ const Map 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': '已撤销', diff --git a/server/app/controller/sessionLog.js b/server/app/controller/sessionLog.js index 85fbae8..0621975 100644 --- a/server/app/controller/sessionLog.js +++ b/server/app/controller/sessionLog.js @@ -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 } diff --git a/server/app/router/routes.js b/server/app/router/routes.js index d9072e4..fd1cdb2 100644 --- a/server/app/router/routes.js +++ b/server/app/router/routes.js @@ -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', diff --git a/server/app/schedule/index.js b/server/app/schedule/index.js index 3c8c95b..980c7e7 100644 --- a/server/app/schedule/index.js +++ b/server/app/schedule/index.js @@ -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) } diff --git a/server/app/utils/login-log.js b/server/app/utils/login-log.js new file mode 100644 index 0000000..0c2b32b --- /dev/null +++ b/server/app/utils/login-log.js @@ -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 +} diff --git a/server/test/test-auth-session.js b/server/test/test-auth-session.js index ca18e97..e6f61a7 100644 --- a/server/test/test-auth-session.js +++ b/server/test/test-auth-session.js @@ -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 = [] diff --git a/web/src/api/index.js b/web/src/api/index.js index 26029be..64b3516 100644 --- a/web/src/api/index.js +++ b/web/src/api/index.js @@ -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' }) diff --git a/web/src/views/setting/components/session.vue b/web/src/views/setting/components/session.vue index cd989e7..0e76fdb 100644 --- a/web/src/views/setting/components/session.vue +++ b/web/src/views/setting/components/session.vue @@ -24,6 +24,14 @@ + + @@ -71,12 +79,12 @@