【底座】锁屏机制跟平台已有纰漏调整

This commit is contained in:
俞宝山
2026-08-07 01:21:49 +08:00
parent 607493f26b
commit 01aab27bc2
28 changed files with 841 additions and 291 deletions
+8 -64
View File
@@ -9,7 +9,7 @@
}
}"
>
<div :class="['app-wrapper', { 'gray-mode': grayModeOpen }]">
<div class="app-wrapper">
<a-watermark
:content="loginUserWatermarkOpen && userInfo ? [userInfo.name, userInfo.account] : undefined"
class="admin-ui-main"
@@ -17,17 +17,16 @@
<router-view />
</a-watermark>
</div>
<lock-screen />
<xn-lock-screen />
</a-config-provider>
</template>
<script setup name="App">
import { onUnmounted, onMounted, nextTick, watch } from 'vue'
import i18n from '@/locales'
import { globalStore } from '@/store'
import { theme } from 'ant-design-vue'
import LockScreen from '@/layout/components/lockScreen.vue'
import tool from '@/utils/tool'
import { useAutoLock } from '@/hooks/useAutoLock'
import { useGrayMode } from '@/hooks/useGrayMode'
const store = globalStore()
store.initTheme()
@@ -38,66 +37,11 @@
const loginUserWatermarkOpen = computed(() => store.loginUserWatermarkOpen)
// 圆角风格
const roundedCornerStyleOpen = computed(() => store.roundedCornerStyleOpen)
// 灰色模式
const grayModeOpen = computed(() => store.grayModeOpen)
// 灰色模式类名
const GRAY_MODE_CLASS = 'gray-mode'
const autoLockTime = computed(() => store.autoLockTime)
let timer = null
// 重置定时器
const resetTimer = () => {
if (timer) {
clearTimeout(timer)
}
const token = tool.data.get('TOKEN')
if (token && autoLockTime.value > 0 && !store.isLocked) {
timer = setTimeout(
() => {
store.setIsLocked(true)
},
autoLockTime.value * 60 * 1000
)
}
}
// 监听灰色模式,同步到 html 元素
watch(
grayModeOpen,
(isEnabled) => {
document.documentElement.classList.toggle(GRAY_MODE_CLASS, isEnabled)
},
{ immediate: true }
)
// 组件卸载时清理
onUnmounted(() => {
document.documentElement.classList.remove(GRAY_MODE_CLASS)
window.removeEventListener('mousedown', resetTimer)
window.removeEventListener('mousemove', resetTimer)
window.removeEventListener('keydown', resetTimer)
window.removeEventListener('touchstart', resetTimer)
window.removeEventListener('scroll', resetTimer)
if (timer) {
clearTimeout(timer)
}
})
onMounted(() => {
window.addEventListener('mousedown', resetTimer)
window.addEventListener('mousemove', resetTimer)
window.addEventListener('keydown', resetTimer)
window.addEventListener('touchstart', resetTimer)
window.addEventListener('scroll', resetTimer)
nextTick(() => {
resetTimer()
})
})
// 监听自动锁屏时间变化
watch(autoLockTime, () => {
resetTimer()
})
// 无操作自动锁屏,事件监听与计时逻辑都收在 hook 内
useAutoLock()
// 灰色模式,类名同步与清理都收在 hook 内
useGrayMode()
</script>
<style lang="less" scoped>
@@ -18,6 +18,22 @@ const request = (url, ...arg) => baseRequest(`/sys/userCenter/` + url, ...arg)
* @date 2022-09-22 22:33:20
*/
export default {
// 开启二级认证
userOpenSafe(data) {
return request('openSafe', data)
},
// 锁定屏幕
userLock(data) {
return request('lock', data)
},
// 解锁屏幕
userUnlock(data) {
return request('unlock', data)
},
// 修改自动锁屏时长
userUpdateAutoLockTime(data) {
return request('updateAutoLockTime', data)
},
// 获取图片验证码
userGetPicCaptcha(data) {
return request('getPicCaptcha', data, 'get')
@@ -9,121 +9,104 @@
<div class="lock-screen-username">{{ userInfo.name }}</div>
<div class="lock-screen-form">
<a-input-password
ref="passwordRef"
v-model:value="password"
placeholder="请输入登录密码解锁"
size="large"
:style="{ borderRadius: 0 }"
:status="errorMsg ? 'error' : ''"
:status="inputStatus"
@keyup.enter="handleUnlock"
@change="errorMsg = ''"
@change="inputStatus = ''"
>
<template #prefix>
<lock-outlined />
</template>
</a-input-password>
<div class="lock-screen-error-msg">{{ errorMsg }}</div>
<a-button
type="primary"
size="large"
block
class="lock-screen-unlock-btn"
:loading="loading"
:style="{ borderRadius: 0 }"
@click="handleUnlock"
>
解锁
</a-button>
<a-button type="link" block class="lock-screen-logout-btn" @click="handleBackToLogin"> 返回登录 </a-button>
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { Modal } from 'ant-design-vue'
<script setup name="lockScreen">
import { useRoute, useRouter } from 'vue-router'
import { LockOutlined } from '@ant-design/icons-vue'
import { globalStore } from '@/store'
import { useMenuStore } from '@/store/menu'
import { useDictStore } from '@/store/dict'
import tool from '@/utils/tool'
import smCrypto from '@/utils/smCrypto'
import axios from 'axios'
import sysConfig from '@/config/index'
import userCenterApi from '@/api/sys/userCenterApi'
const store = globalStore()
const route = useRoute()
const isLocked = computed(() => store.isLocked)
const router = useRouter()
const isDark = computed(() => store.theme === 'realDark')
const userInfo = computed(() => store.userInfo)
const userInfo = computed(() => store.userInfo || {})
const passwordRef = ref()
const password = ref('')
const loading = ref(false)
const errorMsg = ref('')
const inputStatus = ref('')
// 锁屏界面是否可见(排除登录页和无Token状态)
const visible = computed(() => {
const token = tool.data.get('TOKEN')
return isLocked.value && token && route.path !== '/login'
})
// 锁屏界面是否可见,登录页不展示
const visible = computed(() => store.isLocked && route.path !== '/login')
// 解锁
const handleUnlock = async () => {
// 解锁,失败提示由全局错误处理统一弹出
const handleUnlock = () => {
if (!password.value) {
errorMsg.value = '请输入密码'
inputStatus.value = 'error'
return
}
loading.value = true
errorMsg.value = ''
try {
const param = {
password: smCrypto.doSm2Encrypt(password.value)
}
const res = await axios.post('/api/sys/userCenter/unlock', param, {
headers: {
[sysConfig.TOKEN_NAME]: sysConfig.TOKEN_PREFIX + tool.data.get('TOKEN')
}
})
if (res.data.code === 200) {
inputStatus.value = ''
userCenterApi
.userUnlock({ password: smCrypto.doSm2Encrypt(password.value) })
.then(() => {
store.setIsLocked(false)
password.value = ''
} else if (res.data.code === 401 || res.data.code === 1011007 || res.data.code === 1011008) {
handleLogout(true)
} else {
errorMsg.value = res.data.msg || '密码错误'
}
} catch (err) {
if (err.response && (err.response.status === 401 || err.response.status === 403)) {
handleLogout(true)
} else {
errorMsg.value = '解锁失败,请重试'
}
} finally {
loading.value = false
}
}
// 退出登录/处理失效
const handleLogout = (force = false) => {
const doLogout = () => {
tool.data.remove('TOKEN')
tool.data.remove('USER_INFO')
tool.data.remove('MENU')
tool.data.remove('PERMISSIONS')
tool.data.remove('SNOWY_IS_LOCKED')
store.setIsLocked(false)
window.location.reload()
}
if (force) {
doLogout()
} else {
Modal.confirm({
title: '提示',
content: '确定退出登录吗?',
onOk: () => {
doLogout()
}
// 锁屏期间刷新过页面的话,菜单与字典的请求会被4012拦下,解锁后补拉一次
useMenuStore().refreshApiMenu()
useDictStore().refreshDict()
})
.catch(() => {
inputStatus.value = 'error'
})
.finally(() => {
loading.value = false
})
}
}
// 忘记密码等情况下的兜底出口:清空登录态回到登录页重新登录
const handleBackToLogin = () => {
tool.clearLoginCache()
store.setIsLocked(false)
store.setUserInfo(undefined)
router.replace({ path: '/login' }).then(() => {
window.location.reload()
})
}
// 锁屏弹出时自动聚焦密码框,避免还要先点一下输入框
watch(visible, (isVisible) => {
if (isVisible) {
nextTick(() => passwordRef.value?.focus())
} else {
password.value = ''
inputStatus.value = ''
}
})
</script>
<style lang="less" scoped>
@@ -205,16 +188,11 @@
.ant-input-affix-wrapper-lg {
margin-bottom: 0;
}
.lock-screen-error-msg {
color: #ff4d4f;
text-align: left;
font-size: 12px;
min-height: 28px;
line-height: 28px;
padding-left: 2px;
.lock-screen-unlock-btn {
margin-top: 16px;
}
.ant-btn {
margin-top: 4px;
.lock-screen-logout-btn {
margin-top: 8px;
}
}
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Copyright [2022] [https://www.xiaonuo.vip]
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
import { globalStore } from '@/store'
import tool from '@/utils/tool'
import userCenterApi from '@/api/sys/userCenterApi'
// 视为用户活跃的事件,mousemove/scroll 触发频率极高,统一走节流
const ACTIVITY_EVENT_LIST = ['mousedown', 'mousemove', 'keydown', 'touchstart', 'scroll', 'wheel']
// 活跃事件的节流间隔(毫秒),间隔内的重复触发不再重置计时
const ACTIVITY_THROTTLE_INTERVAL = 1000
// 活跃时间写入本地缓存的间隔(毫秒),比上面宽是因为写缓存要读写整个聚合对象,开销大于重置定时器
const SHARED_ACTIVITY_WRITE_INTERVAL = 5000
// tool.data 中 SNOWY_ 前缀键的聚合存储项,用于跨标签页同步锁屏状态
const SNOWY_SETTING_KEY = 'SNOWY_SETTING'
// 最近活跃时间的缓存键,多个标签页共用同一个 token,活跃时间必须共享
const LAST_ACTIVITY_KEY = 'SNOWY_LAST_ACTIVITY'
const setLastActivityAt = (timestamp) => tool.data.set(LAST_ACTIVITY_KEY, timestamp)
/**
* 读取全部标签页共享的最近活跃时间
* 无记录(刚登录)或时间戳晚于当前时刻(改过系统时间)时重置为此刻,避免一进页面就被判定为超时
*/
const getLastActivityAt = () => {
const lastActivityAt = Number(tool.data.get(LAST_ACTIVITY_KEY)) || 0
const now = Date.now()
if (lastActivityAt <= 0 || lastActivityAt > now) {
setLastActivityAt(now)
return now
}
return lastActivityAt
}
/**
* 锁定屏幕,先本地锁屏保证界面立即遮蔽,再请求服务端锁定会话
* 服务端锁定后,除解锁与退出登录外的接口都会返回4012
*/
export const doLockScreen = () => {
globalStore().setIsLocked(true)
userCenterApi.userLock()
}
/**
* 无操作自动锁屏,计时时长取登录用户的 autoLockTime(分钟),为0时不开启
* 已锁屏时停止计时,解锁后自动重新开始计时
*
* 空闲判定基于全部标签页共享的活跃时间:同源标签页共用一个 token,服务端锁定作用在该 token 的会话上,
* 若各标签页各自计时,空闲的那个到点就会把正在使用的那个一起锁掉
*/
export const useAutoLock = () => {
const store = globalStore()
let timer = null
let lastActivityAt = 0
let lastSharedAt = 0
const clearTimer = () => {
if (timer) {
clearTimeout(timer)
timer = null
}
}
// 重置倒计时,未登录、未开启自动锁屏、已锁屏这三种情况都不需要计时
const resetTimer = () => {
clearTimer()
if (!tool.data.get('TOKEN') || store.autoLockTime <= 0 || store.isLocked) {
return
}
const restTime = store.autoLockTime * 60 * 1000 - (Date.now() - getLastActivityAt())
if (restTime <= 0) {
doLockScreen()
return
}
// 到点后不直接锁屏,而是重新走一遍本方法:期间若其他标签页有过操作,共享活跃时间已被刷新,这里会自动顺延
timer = setTimeout(resetTimer, restTime)
}
const handleActivity = () => {
const now = Date.now()
if (now - lastActivityAt < ACTIVITY_THROTTLE_INTERVAL) {
return
}
lastActivityAt = now
// 共享活跃时间按更宽的间隔写入,最坏情况下比真实活跃时间滞后 SHARED_ACTIVITY_WRITE_INTERVAL,
// 相对分钟级的锁屏时长可以忽略
if (now - lastSharedAt >= SHARED_ACTIVITY_WRITE_INTERVAL) {
lastSharedAt = now
setLastActivityAt(now)
}
resetTimer()
}
// 其他标签页锁屏/解锁时同步本标签页,直接赋值以避免再次写回缓存
const handleStorageChange = (event) => {
if (event.key !== SNOWY_SETTING_KEY) {
return
}
const locked = !!tool.data.get('SNOWY_IS_LOCKED')
if (locked !== store.isLocked) {
store.isLocked = locked
}
}
onMounted(() => {
ACTIVITY_EVENT_LIST.forEach((eventName) => window.addEventListener(eventName, handleActivity, { passive: true }))
window.addEventListener('storage', handleStorageChange)
nextTick(() => resetTimer())
})
onUnmounted(() => {
ACTIVITY_EVENT_LIST.forEach((eventName) => window.removeEventListener(eventName, handleActivity))
window.removeEventListener('storage', handleStorageChange)
clearTimer()
})
// 解锁瞬间必须把活跃时间刷到此刻,否则拿锁屏前的旧时间去算,解锁后会立刻又锁上
watch(
() => store.isLocked,
(isLocked, preIsLocked) => {
if (preIsLocked && !isLocked) {
lastSharedAt = Date.now()
setLastActivityAt(lastSharedAt)
}
resetTimer()
}
)
// 时长配置变化时按新时长重新计时
watch(() => store.autoLockTime, resetTimer)
return { resetTimer }
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Copyright [2022] [https://www.xiaonuo.vip]
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
import { globalStore } from '@/store'
// 灰色模式类名,对应样式见 src/style/index.less 的 html.gray-mode
const GRAY_MODE_CLASS = 'gray-mode'
/**
* 灰色模式,跟随 store.grayModeOpen 在 html 元素上增删类名
*
* 滤镜必须加在 html 上:一是 Modal、Drawer 等浮层挂在 body 下,加在业务容器上盖不住;
* 二是 filter 会创建新的包含块,加在中间层会让内部的 position: fixed 失效
*/
export const useGrayMode = () => {
const store = globalStore()
watch(
() => store.grayModeOpen,
(isEnabled) => {
document.documentElement.classList.toggle(GRAY_MODE_CLASS, isEnabled)
},
{ immediate: true }
)
onUnmounted(() => {
document.documentElement.classList.remove(GRAY_MODE_CLASS)
})
}
@@ -71,6 +71,7 @@
import DevUserMessage from './message.vue'
import PanelSearch from './panel-search/index.vue'
import { globalStore, viewTagsStore, keepAliveStore } from '@/store'
import { doLockScreen } from '@/hooks/useAutoLock'
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
@@ -127,11 +128,8 @@
.logout(param)
.then(() => {
// 清理掉个人的一些信息
tool.data.remove('TOKEN')
tool.data.remove('USER_INFO')
tool.data.remove('MENU')
tool.data.remove('PERMISSIONS')
tool.data.remove('SNOWY_IS_LOCKED')
tool.clearLoginCache()
store.setIsLocked(false)
// 清理标签与缓存,避免同一标签页内换号后残留
viewTagsStore().clearViewTags()
keepAliveStore().clearKeepLive()
@@ -169,9 +167,9 @@
screenFull.toggle(element)
}
}
// 锁屏
// 锁屏,本地立即遮蔽的同时通知服务端锁定会话
const lockScreen = () => {
store.setIsLocked(true)
doLockScreen()
}
</script>
-9
View File
@@ -1,6 +1,5 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { message, notification } from 'ant-design-vue'
import './style/index.less'
import snowy from './snowy'
@@ -9,14 +8,6 @@ import router from './router'
import App from './App.vue'
import './tailwind.css'
// 全局配置 message 和 notification 的 z-index,确保在锁屏等高 z-index 组件之上
message.config({
zIndex: 10000
})
notification.config({
zIndex: 10000
})
const app = createApp(App)
app.use(createPinia())
app.use(i18n)
+4 -3
View File
@@ -162,8 +162,8 @@ router.beforeEach(async (to, from, next) => {
tool.data.set('LAST_VIEWS_PATH', to.fullPath)
// 验证menu或则用户信息是否存在,不存在那么就是被删除或者退出或者清理缓存了
if (!tool.data.get('MENU') || !tool.data.get('USER_INFO')) {
tool.data.remove('TOKEN')
tool.data.remove('SNOWY_IS_LOCKED')
tool.clearLoginCache()
store.setIsLocked(false)
next({
path: '/login'
})
@@ -172,7 +172,8 @@ router.beforeEach(async (to, from, next) => {
}
}
if (!token) {
tool.data.remove('SNOWY_IS_LOCKED')
// 无登录态时一定不该处于锁屏,避免登录页被残留的锁屏遮罩盖住
store.setIsLocked(false)
next({
path: '/login'
})
+8
View File
@@ -1,4 +1,5 @@
import * as antdvIcons from '@ant-design/icons-vue'
import { message, notification } from 'ant-design-vue'
import config from './config'
import tool from './utils/tool'
import { hasPerm } from './utils/permission/index'
@@ -11,8 +12,15 @@ import STable from './components/Table/index.vue'
import Ellipsis from './components/Ellipsis/index.vue'
import DragModal from './components/DragModal/index.vue'
// 全局提示的层级,需高于锁屏遮罩(@lock-screen-z-index),否则解锁失败的提示会被遮罩盖住
const GLOBAL_MESSAGE_Z_INDEX = 10000
export default {
install(app) {
// 全局提示层级配置
message.config({ zIndex: GLOBAL_MESSAGE_Z_INDEX })
notification.config({ zIndex: GLOBAL_MESSAGE_Z_INDEX })
// 挂载全局对象
app.config.globalProperties.$CONFIG = config
app.config.globalProperties.$TOOL = tool
+12 -7
View File
@@ -68,10 +68,10 @@ export const globalStore = defineStore('global', () => {
const sysBaseConfig = ref(toolDataGet('SNOWY_SYS_BASE_CONFIG') || config.SYS_BASE_CONFIG)
// 默认应用
const module = ref(getCacheConfig('SNOWY_MENU_MODULE_ID'))
// 锁屏状态
// 锁屏状态,服务端为准,此处仅作本地缓存以便刷新页面时立即呈现锁屏
const isLocked = ref(toolDataGet('SNOWY_IS_LOCKED') || false)
// 自动锁屏时间(分钟),0 为不开启
const autoLockTime = ref(toolDataGet('SNOWY_AUTO_LOCK_TIME') || 0)
// 无操作自动锁屏时长(分钟),0 为不开启,随登录用户信息由服务端下发
const autoLockTime = computed(() => Number(userInfo.value?.autoLockTime) || 0)
// 定义action
const setIsMobile = (key) => {
@@ -81,10 +81,6 @@ export const globalStore = defineStore('global', () => {
isLocked.value = key
tool.data.set('SNOWY_IS_LOCKED', key)
}
const setAutoLockTime = (key) => {
autoLockTime.value = key
tool.data.set('SNOWY_AUTO_LOCK_TIME', key)
}
const setLayout = (key) => {
layout.value = key
}
@@ -147,6 +143,15 @@ export const globalStore = defineStore('global', () => {
}
const setUserInfo = (key) => {
userInfo.value = key
if (key) {
tool.data.set('USER_INFO', key)
} else {
tool.data.remove('USER_INFO')
}
}
// 修改自动锁屏时长,服务端保存成功后同步到本地用户信息
const setAutoLockTime = (key) => {
setUserInfo({ ...userInfo.value, autoLockTime: key })
}
const setSysBaseConfig = (key) => {
sysBaseConfig.value = key
+2 -7
View File
@@ -11,21 +11,16 @@
import { defineStore } from 'pinia'
import loginApi from '@/api/auth/loginApi'
import { useGlobalStore } from '@/store'
import tool from '@/utils/tool'
export const useUserStore = defineStore('useUserStore', () => {
// 初始化用户信息
const initUserInfo = async () => {
const data = await loginApi.getLoginUser()
const globalStore = useGlobalStore()
globalStore.setUserInfo(data)
tool.data.set('USER_INFO', data)
useGlobalStore().setUserInfo(data)
}
// 刷新登录用户信息
const refreshUserLoginUserInfo = () => {
loginApi.getLoginUser().then((data) => {
const globalStore = useGlobalStore()
globalStore.setUserInfo(data)
tool.data.set('USER_INFO', data)
useGlobalStore().setUserInfo(data)
})
}
return { initUserInfo, refreshUserLoginUserInfo }
+2 -1
View File
@@ -68,7 +68,8 @@ export const viewTagsStore = defineStore('viewTags', () => {
const target = viewTags.value.find((item) => item.path === route.path)
const isName = route.name
if (!target && isName) {
viewTags.value.push(route)
// 必须存快照而不是 useRoute() 返回的活对象:vue-router 的路由对象是用
viewTags.value.push({ ...route })
}
if (target) {
updateViewTags(route)
+114
View File
@@ -0,0 +1,114 @@
/**
* Copyright [2022] [https://www.xiaonuo.vip]
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
import { Modal, message, Input } from 'ant-design-vue'
import { h } from 'vue'
import tool from '@/utils/tool'
import smCrypto from '@/utils/smCrypto'
// 直接引 store/global,避免经 store/index 间接依赖 user.js -> loginApi -> request.js 形成循环引用
import { globalStore } from '@/store/global'
// 此处与 userCenterApi 构成循环引用(api -> request -> 本文件),
// 但双方对彼此的引用都在函数体内延迟取值,模块加载期不会触发,故安全;
// 切勿在 userCenterApi 顶层新增立即执行的代码,否则该环会在加载期断裂
import userCenterApi from '@/api/sys/userCenterApi'
/** 需要重新登录的业务码 */
export const RELOGIN_CODES = [401, 1011007, 1011008]
/** 触发二级认证 */
export const SAFE_AUTH_CODE = 4011
/** 会话已被锁屏,除解锁与退出外的接口都会被拦下 */
export const LOCKED_CODE = 4012
/** 解锁连续失败超限,服务端已强制退出登录 */
export const UNLOCK_FAIL_LIMIT_CODE = 4013
// 重登与二级认证弹窗的层级,需高于锁屏遮罩(z-index 2000),否则锁屏时弹出会被遮罩盖住导致点不到
const GLOBAL_MODAL_Z_INDEX = 9000
// 重新登录弹窗是否已弹出,用于保持其唯一性
const loginBack = ref(false)
// 弹出重新登录提示,点击确定后清理登录态并刷新,由路由守卫送回登录页
const showReloginModal = (content) => {
if (loginBack.value) {
return
}
loginBack.value = true
Modal.error({
title: '提示:',
okText: '重新登录',
zIndex: GLOBAL_MODAL_Z_INDEX,
content: content,
onOk: () => {
loginBack.value = false
tool.clearLoginCache()
window.location.reload()
}
})
}
// 登录态失效
export const handleRelogin = () => {
showReloginModal('登录已失效, 请重新登录')
}
// 解锁连续失败超限,服务端已注销会话
export const handleUnlockFailLimit = (data) => {
showReloginModal(data.msg)
return Promise.reject(data)
}
// 服务端会话处于锁屏态,同步本地状态弹出解锁界面,请求本身直接失败
export const handleLocked = (data) => {
globalStore().setIsLocked(true)
return Promise.reject(data)
}
// 开启二级认证的弹窗,认证通过后重试原请求
// service 为 axios 实例,由调用方传入:重放原请求要以 config 直接调用实例本身,api 层封装给不了这个能力
export const handleSafeAuth = (service, config, safeType) => {
return new Promise((resolve, reject) => {
let password = ''
Modal.confirm({
title: '二级认证',
width: 400,
zIndex: GLOBAL_MODAL_Z_INDEX,
content: () =>
h('div', [
h('div', { style: 'margin-bottom: 10px' }, '该操作涉及敏感数据,请验证登录密码'),
h(Input.Password, {
placeholder: '请输入登录密码',
onChange: (e) => {
password = e.target.value
}
})
]),
onOk: () => {
if (!password) {
message.warning('请输入密码')
// 此处必须返回 rejected promise:onOk 返回 falsy 的非 promise 值会被 antd 判定为直接关闭弹窗
return Promise.reject()
}
const param = {
password: smCrypto.doSm2Encrypt(password),
safeType: safeType
}
// 调用开启二级认证接口,认证失败时 rejection 原样透传给 Modal,弹窗保持打开供用户重试
return userCenterApi.userOpenSafe(param).then(() => {
message.success('认证成功')
// 认证成功后重试原请求
resolve(service(config))
})
},
onCancel: () => {
reject()
}
})
})
}
+22 -78
View File
@@ -11,15 +11,22 @@
// 统一的请求发送
import axios from 'axios'
import qs from 'qs'
import { Modal, message, Input } from 'ant-design-vue'
import { h } from 'vue'
import { message } from 'ant-design-vue'
import sysConfig from '@/config/index'
import tool from '@/utils/tool'
import smCrypto from '@/utils/smCrypto'
import { convertUrl } from './apiAdaptive'
// 认证类业务码的具体处理(重登弹窗、二级认证、锁屏)都在这里,本文件只负责识别与分发
import {
RELOGIN_CODES,
SAFE_AUTH_CODE,
LOCKED_CODE,
UNLOCK_FAIL_LIMIT_CODE,
handleRelogin,
handleUnlockFailLimit,
handleLocked,
handleSafeAuth
} from '@/utils/codeHandler'
// 以下这些code需要重新登录
const reloadCodes = [401, 1011007, 1011008]
const errorCodeMap = {
400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。',
401: '用户没有权限(令牌、用户名、密码错误)。',
@@ -34,8 +41,6 @@ const errorCodeMap = {
503: '服务不可用,服务器暂时过载或维护。',
504: '网关超时。'
}
// 定义一个重新登录弹出窗的变量
const loginBack = ref(false)
// 判断是否为直连模式(仅生产环境 + API_URL 配置了完整后端地址时生效)
const isDirectMode = !import.meta.env.DEV && sysConfig.API_URL && /^https?:\/\//.test(sysConfig.API_URL)
// 创建 axios 实例
@@ -63,70 +68,6 @@ service.interceptors.request.use(
}
)
// 保持重新登录Modal的唯一性
const error = () => {
loginBack.value = true
Modal.error({
title: '提示:',
okText: '重新登录',
content: '登录已失效, 请重新登录',
onOk: () => {
loginBack.value = false
tool.data.remove('TOKEN')
tool.data.remove('USER_INFO')
tool.data.remove('MENU')
tool.data.remove('PERMISSIONS')
tool.data.remove('SNOWY_IS_LOCKED')
window.location.reload()
}
})
}
// 开启二级认证的弹窗
const handleSafeAuth = (config, safeType) => {
return new Promise((resolve, reject) => {
let password = ''
Modal.confirm({
title: '二级认证',
width: 400,
content: () =>
h('div', [
h('div', { style: 'margin-bottom: 10px' }, '该操作涉及敏感数据,请验证登录密码'),
h(Input.Password, {
placeholder: '请输入登录密码',
onChange: (e) => {
password = e.target.value
}
})
]),
onOk: () => {
if (!password) {
message.warning('请输入密码')
return Promise.reject()
}
const param = {
password: smCrypto.doSm2Encrypt(password),
safeType: safeType
}
// 调用开启二级认证接口
return service
.post('/sys/userCenter/openSafe', param)
.then(() => {
message.success('认证成功')
// 认证成功后重试原请求
resolve(service(config))
})
.catch(() => {
return Promise.reject()
})
},
onCancel: () => {
reject()
}
})
})
}
// HTTP response 拦截器
service.interceptors.response.use(
(response) => {
@@ -141,14 +82,17 @@ service.interceptors.response.use(
}
const data = response.data
const code = data.code
if (reloadCodes.includes(code)) {
if (!loginBack.value) {
error()
}
return
if (RELOGIN_CODES.includes(code)) {
return handleRelogin()
}
if (code === 4011) {
return handleSafeAuth(response.config, data.data)
if (code === UNLOCK_FAIL_LIMIT_CODE) {
return handleUnlockFailLimit(data)
}
if (code === SAFE_AUTH_CODE) {
return handleSafeAuth(service, response.config, data.data)
}
if (code === LOCKED_CODE) {
return handleLocked(data)
}
if (code !== 200) {
const customErrorMessage = response.config.customErrorMessage
+24 -3
View File
@@ -16,11 +16,16 @@
*/
const tool = {}
// SNOWY_ 前缀的键统一聚合存放在这个 localStorage 项里
const SNOWY_SETTING_KEY = 'SNOWY_SETTING'
// 判断某个键是否被聚合存放(SNOWY_SYS_BASE_CONFIG 体积大,仍单独存放)
const isSnowySettingKey = (table) => table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
// localStorage
tool.data = {
set(table, settings) {
const _set = JSON.stringify(settings)
const SNOWYSTRING = table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
const SNOWYSTRING = isSnowySettingKey(table)
if (SNOWYSTRING) {
let localSetting = JSON.parse(localStorage.getItem('SNOWY_SETTING')) || {}
let newSetting = {}
@@ -29,8 +34,8 @@ tool.data = {
} else return localStorage.setItem(table, _set)
},
get(table) {
const SNOWYSTRING = table.slice(0, 6) === 'SNOWY_' && table !== 'SNOWY_SYS_BASE_CONFIG'
const SNOWY_SETTING = JSON.parse(localStorage.getItem('SNOWY_SETTING')) || {}
const SNOWYSTRING = isSnowySettingKey(table)
const SNOWY_SETTING = JSON.parse(localStorage.getItem(SNOWY_SETTING_KEY)) || {}
let data = SNOWYSTRING ? SNOWY_SETTING[table] : localStorage.getItem(table)
try {
data = JSON.parse(data)
@@ -40,6 +45,12 @@ tool.data = {
return data
},
remove(table) {
// SNOWY_ 前缀的键存在聚合对象里,直接 removeItem 是删不掉的,需从聚合对象中摘除
if (isSnowySettingKey(table)) {
const localSetting = JSON.parse(localStorage.getItem(SNOWY_SETTING_KEY)) || {}
delete localSetting[table]
return localStorage.setItem(SNOWY_SETTING_KEY, JSON.stringify(localSetting))
}
return localStorage.removeItem(table)
},
@@ -48,6 +59,16 @@ tool.data = {
}
}
// 清理B端登录态缓存,退出登录、登录失效、缓存被破坏时统一调用
tool.clearLoginCache = () => {
tool.data.remove('TOKEN')
tool.data.remove('USER_INFO')
tool.data.remove('MENU')
tool.data.remove('PERMISSIONS')
tool.data.remove('SNOWY_IS_LOCKED')
tool.data.remove('SNOWY_LAST_ACTIVITY')
}
// sessionStorage
tool.session = {
set(table, settings) {
@@ -13,6 +13,8 @@ export const afterLogin = async (loginToken, targetPath) => {
const menuStore = useMenuStore()
const userStore = useUserStore()
tool.data.set('TOKEN', loginToken)
// 重新登录一定不处于锁屏,清掉上次会话残留的锁屏状态(服务端同样会在登录时清除)
globalStore().setIsLocked(false)
// 并行初始化用户信息和获取用户的菜单
await Promise.all([userStore.initUserInfo(), menuStore.fetchMenu()])
@@ -22,7 +22,8 @@
v-if="item.type === 'lock'"
v-model:value="autoLockTime"
:min="0"
:max="5"
:max="AUTO_LOCK_TIME_MAX"
:precision="0"
style="width: 120px"
@change="handleLockTimeChange"
/>
@@ -58,13 +59,40 @@
const bindEmailRef = ref()
const bindOtpRef = ref()
const store = globalStore()
// 自动锁屏时长上限(分钟),与后端 SysUserServiceImpl 的校验保持一致
const AUTO_LOCK_TIME_MAX = 720
// 输入防抖时长(毫秒),避免连续输入或连点步进按钮时频繁提交
const SAVE_DEBOUNCE_INTERVAL = 500
const autoLockTime = ref(store.autoLockTime)
let saveTimer = null
// 用户信息刷新后同步输入框,保证展示的始终是服务端的值
watch(
() => store.autoLockTime,
(value) => {
autoLockTime.value = value
}
)
const handleLockTimeChange = (value) => {
store.setAutoLockTime(value || 0)
message.success('设置成功')
if (saveTimer) {
clearTimeout(saveTimer)
}
saveTimer = setTimeout(() => {
const lockTime = Number(value) || 0
// 接口名含 update,request.js 会统一弹成功提示,此处不再重复提示
userCenterApi.userUpdateAutoLockTime({ autoLockTime: lockTime }).then(() => {
store.setAutoLockTime(lockTime)
})
}, SAVE_DEBOUNCE_INTERVAL)
}
onUnmounted(() => {
if (saveTimer) {
clearTimeout(saveTimer)
}
})
const userInfo = computed(() => {
if (store.userInfo) {
return store.userInfo
@@ -0,0 +1,41 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.auth.core.exception;
import lombok.Getter;
/**
* 屏幕锁定异常,当前会话处于锁屏状态时访问业务接口抛出
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
@Getter
public class AuthLockedException extends RuntimeException {
/** 屏幕锁定的状态码,前端据此弹出解锁界面,与二级认证的4011区分 */
public static final int LOCKED_CODE = 4012;
/** 屏幕锁定的提示语 */
public static final String LOCKED_MESSAGE = "屏幕已锁定,请先解锁";
private final Integer code;
private final String msg;
public AuthLockedException() {
super(LOCKED_MESSAGE);
this.code = LOCKED_CODE;
this.msg = LOCKED_MESSAGE;
}
}
@@ -247,6 +247,10 @@ public abstract class SaBaseLoginUser {
@Schema(description = "用户密码hash值")
private String password;
/** 自动锁屏时长,单位分钟,0表示不自动锁屏 */
@Schema(description = "自动锁屏时长,单位分钟,0表示不自动锁屏")
private Integer autoLockTime;
/** 是否可登录,由继承类实现 */
public abstract Boolean getEnabled();
@@ -0,0 +1,99 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.auth.core.util;
import cn.dev33.satoken.session.SaSession;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.convert.Convert;
import cn.hutool.core.util.ObjectUtil;
import vip.xiaonuo.auth.core.exception.AuthLockedException;
/**
* B端锁屏工具类
*
* 锁屏状态存放于TokenSession中,即与token一一对应,锁定某台设备不会波及该账号的其他登录设备。
* 除{@link #unlockByToken}外,其余方法均需在登录校验通过后调用,否则将抛出未登录异常。
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public class StpLockUtil {
/** 锁屏状态在TokenSession中的存储键 */
private static final String LOCK_STATUS_KEY = "screenLocked";
private StpLockUtil() {
}
/**
* 锁定当前会话
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public static void lock() {
StpUtil.getTokenSession().set(LOCK_STATUS_KEY, true);
}
/**
* 解锁当前会话
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public static void unlock() {
SaSession tokenSession = StpUtil.getStpLogic().getTokenSession(false);
if (ObjectUtil.isNotNull(tokenSession)) {
tokenSession.delete(LOCK_STATUS_KEY);
}
}
/**
* 解锁指定token的会话,用于登录成功时清理复用token上残留的锁屏状态
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public static void unlockByToken(String tokenValue) {
SaSession tokenSession = StpUtil.getStpLogic().getTokenSessionByToken(tokenValue, false);
if (ObjectUtil.isNotNull(tokenSession)) {
tokenSession.delete(LOCK_STATUS_KEY);
}
}
/**
* 当前会话是否处于锁屏状态,未锁屏时不会创建TokenSession,避免每次请求校验产生额外的缓存写入
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public static boolean isLocked() {
SaSession tokenSession = StpUtil.getStpLogic().getTokenSession(false);
if (ObjectUtil.isNull(tokenSession)) {
return false;
}
return Convert.toBool(tokenSession.get(LOCK_STATUS_KEY), false);
}
/**
* 校验当前会话未锁屏,已锁屏则抛出异常
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
public static void checkLock() {
if (isLocked()) {
throw new AuthLockedException();
}
}
}
@@ -21,6 +21,7 @@ import org.springframework.stereotype.Component;
import vip.xiaonuo.auth.api.SaBaseLoginUserApi;
import vip.xiaonuo.auth.core.enums.SaClientTypeEnum;
import vip.xiaonuo.auth.core.pojo.SaBaseLoginUser;
import vip.xiaonuo.auth.core.util.StpLockUtil;
import vip.xiaonuo.dev.api.DevLogApi;
/**
@@ -47,6 +48,8 @@ public class AuthListener implements SaTokenListener {
// 更新用户的登录时间和登录ip等信息
if(SaClientTypeEnum.B.getValue().equals(loginType)) {
loginUserApi.updateUserLoginInfo(Convert.toStr(loginId), loginModel.getDeviceType());
// token共享模式下重新登录会复用原token,需清除其上残留的锁屏标记,避免登录后直接处于锁屏态
StpLockUtil.unlockByToken(tokenValue);
// 记录B端登录日志
Object name = loginModel.getExtra("name");
if(ObjectUtil.isNotEmpty(name)) {
@@ -29,6 +29,9 @@ public enum SysRelationCategoryEnum {
/** 用户日程数据 */
SYS_USER_SCHEDULE_DATA("SYS_USER_SCHEDULE_DATA"),
/** 用户锁屏配置数据 */
SYS_USER_LOCK_CONFIG_DATA("SYS_USER_LOCK_CONFIG_DATA"),
/** 用户拥有资源 */
SYS_USER_HAS_RESOURCE("SYS_USER_HAS_RESOURCE"),
@@ -51,6 +51,20 @@ public class SysUserCenterController {
@Resource
private SysUserService sysUserService;
/**
* 锁定屏幕
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
@Operation(summary = "锁定屏幕")
@CommonLog("锁定屏幕")
@PostMapping("/sys/userCenter/lock")
public CommonResult<String> lock() {
sysUserService.lock();
return CommonResult.ok("锁定成功");
}
/**
* 解锁屏幕
*
@@ -65,6 +79,20 @@ public class SysUserCenterController {
return CommonResult.ok("解锁成功");
}
/**
* 修改自动锁屏时长
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
@Operation(summary = "修改自动锁屏时长")
@CommonLog("修改自动锁屏时长")
@PostMapping("/sys/userCenter/updateAutoLockTime")
public CommonResult<String> updateAutoLockTime(@RequestBody @Valid SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam) {
sysUserService.updateAutoLockTime(sysUserUpdateAutoLockTimeParam);
return CommonResult.ok("设置成功");
}
/**
* 开启二级认证
*
@@ -0,0 +1,34 @@
/*
* Copyright [2022] [https://www.xiaonuo.vip]
*
* Snowy采用APACHE LICENSE 2.0开源协议,您在使用过程中,需要注意以下几点:
*
* 1.请不要删除和修改根目录下的LICENSE文件。
* 2.请不要删除和修改Snowy源码头部的版权声明。
* 3.本项目代码可免费商业使用,商业使用请保留源码和相关描述文件的项目出处,作者声明等。
* 4.分发源码时候,请注明软件出处 https://www.xiaonuo.vip
* 5.不可二次分发开源参与同类竞品,如有想法可联系团队xiaonuobase@qq.com商议合作。
* 6.若您的项目无法满足以上几点,需要更多功能代码,获取Snowy商业授权许可,请在官网购买授权,地址为 https://www.xiaonuo.vip
*/
package vip.xiaonuo.sys.modular.user.param;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.Getter;
import lombok.Setter;
/**
* 用户修改自动锁屏时长参数
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
@Getter
@Setter
public class SysUserUpdateAutoLockTimeParam {
/** 自动锁屏时长,单位分钟,0表示不自动锁屏 */
@Schema(description = "自动锁屏时长,单位分钟,0表示不自动锁屏")
@NotNull(message = "autoLockTime不能为空")
private Integer autoLockTime;
}
@@ -39,6 +39,14 @@ import java.util.List;
**/
public interface SysUserService extends IService<SysUser> {
/**
* 锁定屏幕
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
void lock();
/**
* 解锁屏幕
*
@@ -47,6 +55,14 @@ public interface SysUserService extends IService<SysUser> {
**/
void unlock(SysUserUnlockParam sysUserUnlockParam);
/**
* 修改自动锁屏时长
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
void updateAutoLockTime(SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam);
/**
* 开启二级认证
*
@@ -67,6 +67,7 @@ import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import vip.xiaonuo.auth.api.SaBaseLoginUserApi;
import vip.xiaonuo.auth.core.pojo.SaBaseLoginUser;
import vip.xiaonuo.auth.core.util.StpLockUtil;
import vip.xiaonuo.auth.core.util.StpLoginUserUtil;
import vip.xiaonuo.common.cache.CommonCacheOperator;
import vip.xiaonuo.common.enums.CommonGenderEnum;
@@ -210,6 +211,28 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
/** 验证码缓存前缀 */
private static final String USER_VALID_CODE_CACHE_KEY = "user-validCode:";
/** 解锁失败次数缓存前缀 */
private static final String USER_UNLOCK_FAIL_CACHE_KEY = "user-unlockFail:";
/** 解锁允许的最大连续失败次数,达到后强制退出登录 */
private static final int USER_UNLOCK_FAIL_MAX_COUNT = 5;
/** 解锁失败次数的缓存时长,单位:秒 */
private static final int USER_UNLOCK_FAIL_CACHE_TIMEOUT = 30 * 60;
/**
* 解锁连续失败超限的状态码,前端据此清理登录态并跳转登录页
* 不能复用401:401 走的是前端通用的“登录已失效”弹窗,该分支不会 reject 请求,
* 会被解锁界面误判成解锁成功,同属认证类状态码,与4011二级认证、4012屏幕锁定并列
*/
private static final int USER_UNLOCK_FAIL_LIMIT_CODE = 4013;
/** 自动锁屏时长上限,单位:分钟 */
private static final int USER_AUTO_LOCK_TIME_MAX = 720;
/** 自动锁屏时长在关系表扩展信息中的键名 */
private static final String USER_AUTO_LOCK_TIME_KEY = "autoLockTime";
@Resource
private CommonCacheOperator commonCacheOperator;
@@ -270,20 +293,77 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
@Resource(name = "loginUserApi")
private SaBaseLoginUserApi loginUserApi;
@Override
public void lock() {
StpLockUtil.lock();
}
@Override
public void unlock(SysUserUnlockParam sysUserUnlockParam) {
SysUser sysUser = this.queryEntity(StpUtil.getLoginIdAsString());
String password = CommonCryptogramUtil.doSm2Decrypt(sysUserUnlockParam.getPassword()).trim();
if (!CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword())) {
throw new CommonException("密码错误");
String userId = StpUtil.getLoginIdAsString();
String failCacheKey = USER_UNLOCK_FAIL_CACHE_KEY + userId;
if (!this.matchPassword(userId, sysUserUnlockParam.getPassword())) {
int failCount = Convert.toInt(commonCacheOperator.get(failCacheKey), 0) + 1;
// 连续失败达到上限,强制下线,避免锁屏界面被无限次尝试密码
if (failCount >= USER_UNLOCK_FAIL_MAX_COUNT) {
commonCacheOperator.remove(failCacheKey);
StpUtil.logout();
throw new CommonException(USER_UNLOCK_FAIL_LIMIT_CODE, "密码错误次数过多,已强制退出登录,请重新登录");
}
commonCacheOperator.put(failCacheKey, failCount, USER_UNLOCK_FAIL_CACHE_TIMEOUT);
throw new CommonException("密码错误,还可尝试{}次", USER_UNLOCK_FAIL_MAX_COUNT - failCount);
}
commonCacheOperator.remove(failCacheKey);
StpLockUtil.unlock();
}
@Override
public void updateAutoLockTime(SysUserUpdateAutoLockTimeParam sysUserUpdateAutoLockTimeParam) {
Integer autoLockTime = sysUserUpdateAutoLockTimeParam.getAutoLockTime();
if (autoLockTime < 0 || autoLockTime > USER_AUTO_LOCK_TIME_MAX) {
throw new CommonException("自动锁屏时长应在0至{}分钟之间", USER_AUTO_LOCK_TIME_MAX);
}
// 存关系表而非用户表字段,避免为一个个性化配置改动SYS_USER表结构,与工作台数据、日程数据同一套做法
sysRelationService.saveRelationWithClear(StpUtil.getLoginIdAsString(), null,
SysRelationCategoryEnum.SYS_USER_LOCK_CONFIG_DATA.getValue(),
JSONUtil.createObj().set(USER_AUTO_LOCK_TIME_KEY, autoLockTime).toString());
}
/**
* 获取指定用户的自动锁屏时长,未配置过则返回0(不自动锁屏)
*
* @author xuyuxiang
* @date 2026/8/7 10:20
**/
private Integer getAutoLockTime(String userId) {
SysRelation sysRelation = sysRelationService.getOne(new LambdaQueryWrapper<SysRelation>()
.eq(SysRelation::getObjectId, userId)
.eq(SysRelation::getCategory, SysRelationCategoryEnum.SYS_USER_LOCK_CONFIG_DATA.getValue()));
if (ObjectUtil.isNull(sysRelation) || ObjectUtil.isEmpty(sysRelation.getExtJson())) {
return 0;
}
return JSONUtil.parseObj(sysRelation.getExtJson()).getInt(USER_AUTO_LOCK_TIME_KEY, 0);
}
/**
* 用户实体转登录用户,自动锁屏时长不在SYS_USER表上,BeanUtil拷不到,需单独回填
*
* @author xuyuxiang
* @date 2026/8/7 10:20
**/
private SysLoginUser convertToLoginUser(SysUser sysUser) {
if (ObjectUtil.isEmpty(sysUser)) {
return null;
}
transService.transOne(sysUser);
SysLoginUser sysLoginUser = BeanUtil.copyProperties(sysUser, SysLoginUser.class);
sysLoginUser.setAutoLockTime(this.getAutoLockTime(sysUser.getId()));
return sysLoginUser;
}
@Override
public void openSafe(SysUserOpenSafeParam sysUserOpenSafeParam) {
SysUser sysUser = this.queryEntity(StpUtil.getLoginIdAsString());
String password = CommonCryptogramUtil.doSm2Decrypt(sysUserOpenSafeParam.getPassword()).trim();
if (!CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword())) {
if (!this.matchPassword(StpUtil.getLoginIdAsString(), sysUserOpenSafeParam.getPassword())) {
throw new CommonException("密码错误");
}
// 比对成功,为当前会话打开二级认证,有效期为120秒
@@ -294,44 +374,37 @@ public class SysUserServiceImpl extends ServiceImpl<SysUserMapper, SysUser> impl
}
}
/**
* 校验指定用户的密码是否正确,入参为国密加密后的密码
*
* @author xuyuxiang
* @date 2026/8/6 10:20
**/
private boolean matchPassword(String userId, String encryptPassword) {
SysUser sysUser = this.queryEntity(userId);
String password = CommonCryptogramUtil.doSm2Decrypt(encryptPassword).trim();
return CommonCryptogramUtil.doHashValue(password).equals(sysUser.getPassword());
}
@Override
public SysLoginUser getUserById(String id) {
SysUser sysUser = this.getById(id);
if (ObjectUtil.isNotEmpty(sysUser)) {
transService.transOne(sysUser);
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
}
return null;
return this.convertToLoginUser(this.getById(id));
}
@Override
public SysLoginUser getUserByAccount(String account) {
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getAccount, account));
if (ObjectUtil.isNotEmpty(sysUser)) {
transService.transOne(sysUser);
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
}
return null;
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getAccount, account)));
}
@Override
public SysLoginUser getUserByPhone(String phone) {
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getPhone, CommonCryptogramUtil.doSm4CbcEncrypt(phone)));
if (ObjectUtil.isNotEmpty(sysUser)) {
transService.transOne(sysUser);
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
}
return null;
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getPhone, CommonCryptogramUtil.doSm4CbcEncrypt(phone))));
}
@Override
public SysLoginUser getUserByEmail(String email) {
SysUser sysUser = this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email));
if (ObjectUtil.isNotEmpty(sysUser)) {
transService.transOne(sysUser);
return BeanUtil.copyProperties(sysUser, SysLoginUser.class);
}
return null;
return this.convertToLoginUser(this.getOne(new LambdaQueryWrapper<SysUser>().eq(SysUser::getEmail, email)));
}
@Override
@@ -64,6 +64,7 @@ import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import vip.xiaonuo.auth.core.util.StpClientUtil;
import vip.xiaonuo.auth.core.util.StpLockUtil;
import vip.xiaonuo.common.annotation.CommonNoRepeat;
import vip.xiaonuo.common.annotation.CommonWrapper;
import vip.xiaonuo.common.cache.CommonCacheOperator;
@@ -249,6 +250,17 @@ public class GlobalConfigure implements WebMvcConfigurer {
"/client/c/**"
};
/**
* 锁屏状态下依然放行的接口地址集合,即解锁自身、退出登录与登录态查询
*/
private static final String[] NO_LOCK_CHECK_PATH_ARR = {
"/sys/userCenter/lock",
"/sys/userCenter/unlock",
"/auth/b/getLoginUser",
"/auth/b/doLogout",
"/auth/b/isLogin"
};
/**
* 注册跨域过滤器
*/
@@ -273,6 +285,17 @@ public class GlobalConfigure implements WebMvcConfigurer {
StpUtil.renewTimeout(saTokenConfig.getTimeout());
});
// B端的接口校验锁屏状态,锁屏期间除解锁与退出外一律拒绝
SaRouter.match("/**")
// 排除无需登录接口
.notMatch(CollectionUtil.newArrayList(NO_LOGIN_PATH_ARR))
// 排除C端认证接口
.notMatch(CollectionUtil.newArrayList(CLIENT_USER_PERMISSION_PATH_ARR))
// 排除锁屏期间放行的接口
.notMatch(CollectionUtil.newArrayList(NO_LOCK_CHECK_PATH_ARR))
// 校验B端锁屏状态
.check(r1 -> StpLockUtil.checkLock());
// C端的接口校验C端登录
SaRouter.match("/**")
// 排除无需登录接口
@@ -32,6 +32,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.multipart.MultipartException;
import org.springframework.web.multipart.support.MissingServletRequestPartException;
import vip.xiaonuo.auth.core.exception.AuthLockedException;
import vip.xiaonuo.auth.core.util.AuthExceptionUtil;
import vip.xiaonuo.common.exception.CommonException;
import vip.xiaonuo.common.pojo.CommonResult;
@@ -111,6 +112,9 @@ public class GlobalExceptionUtil {
log.error(">>> 文件上传参数异常:", e);
//文件上传错误特殊提示
commonResult = CommonResult.error("请选择要上传的文件并检查文件参数名称是否正确");
} else if (e instanceof AuthLockedException authLockedException) {
// 如果是屏幕锁定异常,返回4012,前端据此弹出解锁界面
commonResult = CommonResult.get(authLockedException.getCode(), authLockedException.getMsg(), null);
} else if (e instanceof SaTokenException) {
// 如果是SaToken相关异常,则由AuthExceptionUtil处理