xsh_1997 hace 1 mes
padre
commit
68cb174608

+ 1 - 1
ruoyi-screen/.env.development

@@ -15,5 +15,5 @@ VITE_WEATHER_APP_ID=36793262
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
15
 VITE_WEATHER_APP_SECRET=t6a6z0H8
16
 VITE_WEATHER_CITY=巴青
16
 VITE_WEATHER_CITY=巴青
17
 
17
 
18
-# 大屏免密登录(RSA 加密 UUID,见 doc/大屏/免密登录/大屏免密登录技术方案.md
18
+# 大屏免密登录(获取公钥 + RSA 加密 UUID)
19
 VITE_SCREEN_AUTO_LOGIN=true
19
 VITE_SCREEN_AUTO_LOGIN=true

+ 72 - 16
ruoyi-screen/src/router/index.js

@@ -1,7 +1,15 @@
1
 import { createRouter, createWebHistory } from "vue-router"
1
 import { createRouter, createWebHistory } from "vue-router"
2
 import ScreenLayout from "../layout/ScreenLayout.vue"
2
 import ScreenLayout from "../layout/ScreenLayout.vue"
3
 import { getToken } from "../utils/auth"
3
 import { getToken } from "../utils/auth"
4
-import { ensureScreenAutoLogin, isScreenAutoLoginEnabled } from "../utils/screenAutoLogin"
4
+import {
5
+  ensureScreenAutoLogin,
6
+  getUrlScreenLoginCipher,
7
+  SCREEN_LOGIN_CIPHER_ERROR,
8
+  SCREEN_LOGIN_UUID_ERROR,
9
+  stripScreenLoginTokenQuery,
10
+  stripScreenLoginUuidQuery,
11
+  tryScreenLoginWithCipher
12
+} from "../utils/screenAutoLogin"
5
 
13
 
6
 /**
14
 /**
7
  * 大屏路由表(路径与后端 /bigScreen/* 业务对应,便于联调)
15
  * 大屏路由表(路径与后端 /bigScreen/* 业务对应,便于联调)
@@ -88,7 +96,59 @@ const router = createRouter({
88
   routes
96
   routes
89
 })
97
 })
90
 
98
 
91
-/** 未登录跳转登录页;已登录访问 /login 则进首页;启用免密时先尝试自动登录 */
99
+/** URL ?token= 密文登录;失败则提示公钥不对并跳转登录页 */
100
+async function tryLoginWithUrlToken(to, next) {
101
+  const cipher = getUrlScreenLoginCipher(to.query)
102
+  if (!cipher) {
103
+    return false
104
+  }
105
+  try {
106
+    await tryScreenLoginWithCipher(cipher)
107
+    const query = stripScreenLoginTokenQuery(to.query)
108
+    if (to.path === "/login") {
109
+      const redirect =
110
+        typeof to.query.redirect === "string" ? to.query.redirect : "/home"
111
+      next({ path: redirect, replace: true })
112
+      return true
113
+    }
114
+    next({ path: to.path, query, replace: true })
115
+    return true
116
+  } catch {
117
+    next({
118
+      path: "/login",
119
+      query: { screenLoginError: SCREEN_LOGIN_CIPHER_ERROR },
120
+      replace: true
121
+    })
122
+    return true
123
+  }
124
+}
125
+
126
+/** URL ?uuid= 免密登录;失败则提示并跳转登录页 */
127
+async function tryLoginWithUrlUuid(to, next) {
128
+  const { attempted, ok } = await ensureScreenAutoLogin(to.query)
129
+  if (!attempted) {
130
+    return false
131
+  }
132
+  if (ok) {
133
+    const query = stripScreenLoginUuidQuery(to.query)
134
+    if (to.path === "/login") {
135
+      const redirect =
136
+        typeof to.query.redirect === "string" ? to.query.redirect : "/home"
137
+      next({ path: redirect, replace: true })
138
+      return true
139
+    }
140
+    next({ path: to.path, query, replace: true })
141
+    return true
142
+  }
143
+  next({
144
+    path: "/login",
145
+    query: { screenLoginError: SCREEN_LOGIN_UUID_ERROR },
146
+    replace: true
147
+  })
148
+  return true
149
+}
150
+
151
+/** 未登录跳转登录页;URL ?token= / ?uuid= 优先 */
92
 router.beforeEach(async (to, _from, next) => {
152
 router.beforeEach(async (to, _from, next) => {
93
   const token = getToken()
153
   const token = getToken()
94
   if (to.path === "/login") {
154
   if (to.path === "/login") {
@@ -96,26 +156,22 @@ router.beforeEach(async (to, _from, next) => {
96
       next({ path: "/home" })
156
       next({ path: "/home" })
97
       return
157
       return
98
     }
158
     }
99
-    if (isScreenAutoLoginEnabled()) {
100
-      const ok = await ensureScreenAutoLogin()
101
-      if (ok) {
102
-        const redirect =
103
-          typeof to.query.redirect === "string" ? to.query.redirect : "/home"
104
-        next({ path: redirect })
105
-        return
106
-      }
159
+    if (await tryLoginWithUrlToken(to, next)) {
160
+      return
161
+    }
162
+    if (await tryLoginWithUrlUuid(to, next)) {
163
+      return
107
     }
164
     }
108
     next()
165
     next()
109
     return
166
     return
110
   }
167
   }
111
   const needAuth = to.matched.some((r) => r.meta.requiresAuth)
168
   const needAuth = to.matched.some((r) => r.meta.requiresAuth)
112
   if (needAuth && !getToken()) {
169
   if (needAuth && !getToken()) {
113
-    if (isScreenAutoLoginEnabled()) {
114
-      const ok = await ensureScreenAutoLogin()
115
-      if (ok) {
116
-        next()
117
-        return
118
-      }
170
+    if (await tryLoginWithUrlToken(to, next)) {
171
+      return
172
+    }
173
+    if (await tryLoginWithUrlUuid(to, next)) {
174
+      return
119
     }
175
     }
120
     next({ path: "/login", query: { redirect: to.fullPath } })
176
     next({ path: "/login", query: { redirect: to.fullPath } })
121
     return
177
     return

+ 116 - 30
ruoyi-screen/src/utils/screenAutoLogin.js

@@ -2,9 +2,16 @@ import JSEncrypt from 'jsencrypt/bin/jsencrypt.min'
2
 import { getScreenPublicKey, screenAutoLogin } from '@/api/login'
2
 import { getScreenPublicKey, screenAutoLogin } from '@/api/login'
3
 import { getToken, setToken } from '@/utils/auth'
3
 import { getToken, setToken } from '@/utils/auth'
4
 
4
 
5
+/** URL 密文登录失败时展示给用户的提示 */
6
+export const SCREEN_LOGIN_CIPHER_ERROR = '公钥不对'
7
+
8
+/** URL uuid 免密登录失败时展示给用户的提示 */
9
+export const SCREEN_LOGIN_UUID_ERROR = '免密失败,请联系管理员获取免登ID'
10
+
5
 /** 同一会话内仅尝试一次免密登录,避免反复请求 */
11
 /** 同一会话内仅尝试一次免密登录,避免反复请求 */
6
 let autoLoginState = null
12
 let autoLoginState = null
7
 let autoLoginPromise = null
13
 let autoLoginPromise = null
14
+let autoLoginUuid = null
8
 
15
 
9
 export function isScreenAutoLoginEnabled() {
16
 export function isScreenAutoLoginEnabled() {
10
   return import.meta.env.VITE_SCREEN_AUTO_LOGIN === 'true'
17
   return import.meta.env.VITE_SCREEN_AUTO_LOGIN === 'true'
@@ -13,58 +20,136 @@ export function isScreenAutoLoginEnabled() {
13
 export function resetScreenAutoLoginState() {
20
 export function resetScreenAutoLoginState() {
14
   autoLoginState = null
21
   autoLoginState = null
15
   autoLoginPromise = null
22
   autoLoginPromise = null
23
+  autoLoginUuid = null
24
+}
25
+
26
+/** 从路由 query 读取 RSA 密文(?token=xxxx) */
27
+export function getUrlScreenLoginCipher(query) {
28
+  const raw = query?.token
29
+  if (typeof raw !== 'string' || !raw.trim()) {
30
+    return ''
31
+  }
32
+  try {
33
+    return decodeURIComponent(raw.trim())
34
+  } catch {
35
+    return raw.trim()
36
+  }
37
+}
38
+
39
+/** 从路由 query 读取免登 UUID(?uuid=xxxx) */
40
+export function getUrlScreenLoginUuid(query) {
41
+  const raw = query?.uuid
42
+  if (typeof raw !== 'string' || !raw.trim()) {
43
+    return ''
44
+  }
45
+  try {
46
+    return decodeURIComponent(raw.trim())
47
+  } catch {
48
+    return raw.trim()
49
+  }
50
+}
51
+
52
+/** 去掉 URL 中的 token,避免密文残留在地址栏 */
53
+export function stripScreenLoginTokenQuery(query = {}) {
54
+  const next = { ...query }
55
+  delete next.token
56
+  return next
57
+}
58
+
59
+/** 去掉 URL 中的 uuid */
60
+export function stripScreenLoginUuidQuery(query = {}) {
61
+  const next = { ...query }
62
+  delete next.uuid
63
+  return next
16
 }
64
 }
17
 
65
 
18
 /**
66
 /**
19
- * RSA 加密 UUID 换取 JWT(见 doc/大屏/免密登录/大屏免密登录技术方案.md)
67
+ * 使用 URL 传入的 RSA 密文换取 JWT
68
+ * @param {string} cipher
20
  * @returns {Promise<string>} JWT
69
  * @returns {Promise<string>} JWT
21
  */
70
  */
22
-export async function tryScreenAutoLogin() {
23
-  const keyRes = await getScreenPublicKey()
24
-  const publicKey = keyRes.data?.publicKey
25
-  if (!publicKey) {
26
-    throw new Error('未获取到 RSA 公钥')
71
+export async function tryScreenLoginWithCipher(cipher) {
72
+  if (!cipher) {
73
+    throw new Error(SCREEN_LOGIN_CIPHER_ERROR)
74
+  }
75
+  try {
76
+    const loginRes = await screenAutoLogin(cipher)
77
+    if (!loginRes?.token) {
78
+      throw new Error(SCREEN_LOGIN_CIPHER_ERROR)
79
+    }
80
+    setToken(loginRes.token)
81
+    autoLoginState = 'ok'
82
+    return loginRes.token
83
+  } catch {
84
+    throw new Error(SCREEN_LOGIN_CIPHER_ERROR)
27
   }
85
   }
86
+}
28
 
87
 
29
-  const uuid = crypto.randomUUID()
30
-  const encryptor = new JSEncrypt()
31
-  encryptor.setPublicKey(publicKey)
32
-  const cipher = encryptor.encrypt(uuid)
33
-  if (!cipher) {
34
-    throw new Error('RSA 加密失败')
88
+/**
89
+ * 获取公钥 → RSA 加密 URL 中的 UUID → 换取 JWT
90
+ * @param {string} uuid
91
+ * @returns {Promise<string>} JWT
92
+ */
93
+export async function tryScreenAutoLogin(uuid) {
94
+  if (!uuid) {
95
+    throw new Error(SCREEN_LOGIN_UUID_ERROR)
35
   }
96
   }
97
+  try {
98
+    const keyRes = await getScreenPublicKey()
99
+    const publicKey = keyRes.data?.publicKey
100
+    if (!publicKey) {
101
+      throw new Error(SCREEN_LOGIN_UUID_ERROR)
102
+    }
103
+
104
+    const encryptor = new JSEncrypt()
105
+    encryptor.setPublicKey(publicKey)
106
+    const cipher = encryptor.encrypt(uuid)
107
+    if (!cipher) {
108
+      throw new Error(SCREEN_LOGIN_UUID_ERROR)
109
+    }
36
 
110
 
37
-  const loginRes = await screenAutoLogin(cipher)
38
-  if (!loginRes.token) {
39
-    throw new Error('免密登录未返回 token')
111
+    const loginRes = await screenAutoLogin(cipher)
112
+    if (!loginRes?.token) {
113
+      throw new Error(SCREEN_LOGIN_UUID_ERROR)
114
+    }
115
+    setToken(loginRes.token)
116
+    return loginRes.token
117
+  } catch {
118
+    throw new Error(SCREEN_LOGIN_UUID_ERROR)
40
   }
119
   }
41
-  setToken(loginRes.token)
42
-  return loginRes.token
43
 }
120
 }
44
 
121
 
45
 /**
122
 /**
46
- * 路由守卫用:已登录直接通过;未启用免密则 false;否则尝试一次免密登录
47
- * @returns {Promise<boolean>}
123
+ * 路由守卫用:URL 含 uuid 时尝试免密登录
124
+ * @returns {Promise<{ attempted: boolean, ok: boolean }>}
48
  */
125
  */
49
-export async function ensureScreenAutoLogin() {
126
+export async function ensureScreenAutoLogin(query) {
50
   if (getToken()) {
127
   if (getToken()) {
51
-    return true
128
+    return { attempted: false, ok: true }
52
   }
129
   }
53
   if (!isScreenAutoLoginEnabled()) {
130
   if (!isScreenAutoLoginEnabled()) {
54
-    return false
131
+    return { attempted: false, ok: false }
55
   }
132
   }
56
-  if (autoLoginState === 'fail') {
57
-    return false
133
+
134
+  const uuid = getUrlScreenLoginUuid(query)
135
+  if (!uuid) {
136
+    return { attempted: false, ok: false }
137
+  }
138
+
139
+  if (autoLoginState === 'fail' && autoLoginUuid === uuid) {
140
+    return { attempted: true, ok: false }
58
   }
141
   }
59
-  if (autoLoginState === 'ok') {
60
-    return !!getToken()
142
+  if (autoLoginState === 'ok' && getToken()) {
143
+    return { attempted: false, ok: true }
61
   }
144
   }
62
-  if (autoLoginState === 'pending' && autoLoginPromise) {
63
-    return autoLoginPromise
145
+  if (autoLoginState === 'pending' && autoLoginPromise && autoLoginUuid === uuid) {
146
+    const ok = await autoLoginPromise
147
+    return { attempted: true, ok }
64
   }
148
   }
65
 
149
 
150
+  autoLoginUuid = uuid
66
   autoLoginState = 'pending'
151
   autoLoginState = 'pending'
67
-  autoLoginPromise = tryScreenAutoLogin()
152
+  autoLoginPromise = tryScreenAutoLogin(uuid)
68
     .then(() => {
153
     .then(() => {
69
       autoLoginState = 'ok'
154
       autoLoginState = 'ok'
70
       return true
155
       return true
@@ -74,5 +159,6 @@ export async function ensureScreenAutoLogin() {
74
       return false
159
       return false
75
     })
160
     })
76
 
161
 
77
-  return autoLoginPromise
162
+  const ok = await autoLoginPromise
163
+  return { attempted: true, ok }
78
 }
164
 }

+ 7 - 0
ruoyi-screen/src/views/login/index.vue

@@ -102,6 +102,13 @@ function onKeyupEnter() {
102
 }
102
 }
103
 
103
 
104
 onMounted(() => {
104
 onMounted(() => {
105
+  const urlError = route.query.screenLoginError
106
+  if (typeof urlError === 'string' && urlError) {
107
+    errorMsg.value = urlError
108
+    const query = { ...route.query }
109
+    delete query.screenLoginError
110
+    router.replace({ path: '/login', query })
111
+  }
105
   loadRemembered()
112
   loadRemembered()
106
   refreshCaptcha()
113
   refreshCaptcha()
107
 })
114
 })

+ 1 - 1
ruoyi-ui/src/layout/components/Sidebar/Logo.vue

@@ -80,7 +80,7 @@ export default {
80
       color: #fff;
80
       color: #fff;
81
       font-weight: 600;
81
       font-weight: 600;
82
       line-height: 50px;
82
       line-height: 50px;
83
-      font-size: 11px;
83
+      font-size: 14px;
84
       font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
84
       font-family: Avenir, Helvetica Neue, Arial, Helvetica, sans-serif;
85
       vertical-align: middle;
85
       vertical-align: middle;
86
     }
86
     }