hookehuyr

fix(auth): restore hash route after oauth redirect

...@@ -9,9 +9,10 @@ import { createApp } from 'vue' ...@@ -9,9 +9,10 @@ import { createApp } from 'vue'
9 import './style.css' 9 import './style.css'
10 import App from './App.vue' 10 import App from './App.vue'
11 import router from './router' 11 import router from './router'
12 -import axios from '@/utils/axios'; 12 +import axios from '@/utils/axios'
13 +import { restoreHashAfterOAuth } from '@/utils/oauthHashRestore'
13 import 'vant/lib/index.css' 14 import 'vant/lib/index.css'
14 -import '@vant/touch-emulator'; 15 +import '@vant/touch-emulator'
15 16
16 /* import the fontawesome core */ 17 /* import the fontawesome core */
17 import { library } from '@fortawesome/fontawesome-svg-core' 18 import { library } from '@fortawesome/fontawesome-svg-core'
...@@ -19,47 +20,80 @@ import { library } from '@fortawesome/fontawesome-svg-core' ...@@ -19,47 +20,80 @@ import { library } from '@fortawesome/fontawesome-svg-core'
19 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome' 20 import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
20 import { Icon as IconifyIcon } from '@iconify/vue' 21 import { Icon as IconifyIcon } from '@iconify/vue'
21 /* import specific icons */ 22 /* import specific icons */
22 -import { faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark, faFileAlt, faTimes, faEye, faFilePdf, faExternalLinkAlt, faSpinner, faExclamationCircle, faDownload, faVenus, faMars, faMagnifyingGlassPlus, faMagnifyingGlassMinus } from '@fortawesome/free-solid-svg-icons' 23 +import {
24 + faCirclePause,
25 + faCirclePlay,
26 + faPlay,
27 + faPause,
28 + faBackwardStep,
29 + faForwardStep,
30 + faVolumeUp,
31 + faRedo,
32 + faRepeat,
33 + faList,
34 + faChevronDown,
35 + faVolumeOff,
36 + faXmark,
37 + faFileAlt,
38 + faTimes,
39 + faEye,
40 + faFilePdf,
41 + faExternalLinkAlt,
42 + faSpinner,
43 + faExclamationCircle,
44 + faDownload,
45 + faVenus,
46 + faMars,
47 + faMagnifyingGlassPlus,
48 + faMagnifyingGlassMinus,
49 +} from '@fortawesome/free-solid-svg-icons'
23 50
24 /* add icons to the library */ 51 /* add icons to the library */
25 -library.add(faCirclePause, faCirclePlay, faPlay, faPause, faBackwardStep, faForwardStep, faVolumeUp, faRedo, faRepeat, faList, faChevronDown, faVolumeOff, faXmark, faFileAlt, faTimes, faEye, faFilePdf, faExternalLinkAlt, faSpinner, faExclamationCircle, faDownload, faVenus, faMars, faMagnifyingGlassPlus, faMagnifyingGlassMinus ) 52 +library.add(
53 + faCirclePause,
54 + faCirclePlay,
55 + faPlay,
56 + faPause,
57 + faBackwardStep,
58 + faForwardStep,
59 + faVolumeUp,
60 + faRedo,
61 + faRepeat,
62 + faList,
63 + faChevronDown,
64 + faVolumeOff,
65 + faXmark,
66 + faFileAlt,
67 + faTimes,
68 + faEye,
69 + faFilePdf,
70 + faExternalLinkAlt,
71 + faSpinner,
72 + faExclamationCircle,
73 + faDownload,
74 + faVenus,
75 + faMars,
76 + faMagnifyingGlassPlus,
77 + faMagnifyingGlassMinus
78 +)
26 79
27 if (!Array.prototype.at) { 80 if (!Array.prototype.at) {
28 - Array.prototype.at = function(n) { 81 + Array.prototype.at = function (n) {
29 - n = Math.trunc(n) || 0; 82 + n = Math.trunc(n) || 0
30 - if (n < 0) n += this.length; 83 + if (n < 0) n += this.length
31 - if (n < 0 || n >= this.length) return undefined; 84 + if (n < 0 || n >= this.length) return undefined
32 - return this[n]; 85 + return this[n]
33 - }; 86 + }
34 } 87 }
35 const app = createApp(App) 88 const app = createApp(App)
36 app.component('Icon', IconifyIcon) 89 app.component('Icon', IconifyIcon)
37 -app.component('font-awesome-icon', FontAwesomeIcon) 90 +app.component('FontAwesomeIcon', FontAwesomeIcon)
38 // 屏蔽警告信息 91 // 屏蔽警告信息
39 -app.config.warnHandler = () => null; 92 +app.config.warnHandler = () => null
40 -
41 -app.config.globalProperties.$http = axios; // 关键语句
42 -/**
43 - * @function restoreHashAfterOAuth
44 - * @description 前端复原 OAuth 回跳的 hash 路由位置:当 URL 中存在 ret_hash 参数且当前无 hash 时,将其拼回地址栏。
45 - * @returns {void}
46 - */
47 -// function restoreHashAfterOAuth() {
48 -// const url = new URL(window.location.href);
49 -// const ret_hash = url.searchParams.get('ret_hash');
50 -// if (ret_hash && !window.location.hash) {
51 -// // 删除 ret_hash,保留其他查询参数
52 -// url.searchParams.delete('ret_hash');
53 -// const base = url.toString().split('#')[0];
54 -// const new_url = base + ret_hash;
55 -// // 使用 replaceState 避免再次刷新与历史记录污染
56 -// window.history.replaceState(null, '', new_url);
57 -// }
58 -// }
59 -// void restoreHashAfterOAuth
60 93
94 +app.config.globalProperties.$http = axios // 关键语句
61 // 在安装路由前进行一次 hash 复原,确保初始路由正确 95 // 在安装路由前进行一次 hash 复原,确保初始路由正确
62 -// restoreHashAfterOAuth() 96 +restoreHashAfterOAuth()
63 app.use(router) 97 app.use(router)
64 98
65 // 开发环境添加欢迎页调试工具 99 // 开发环境添加欢迎页调试工具
......
...@@ -6,34 +6,35 @@ ...@@ -6,34 +6,35 @@
6 * @Description: 路由守卫逻辑 6 * @Description: 路由守卫逻辑
7 */ 7 */
8 import { getAuthInfoAPI } from '@/api/auth' 8 import { getAuthInfoAPI } from '@/api/auth'
9 -import { wxInfo } from "@/utils/tools" 9 +import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore'
10 +import { wxInfo } from '@/utils/tools'
10 11
11 // TAG: 需要登录才能访问的路由 12 // TAG: 需要登录才能访问的路由
12 export const authRequiredRoutes = [ 13 export const authRequiredRoutes = [
13 - { 14 + {
14 - path: '/profile', 15 + path: '/profile',
15 - exact: false, 16 + exact: false,
16 - }, 17 + },
17 - { 18 + {
18 - path: '/checkout', 19 + path: '/checkout',
19 - exact: true, 20 + exact: true,
20 - }, 21 + },
21 - { 22 + {
22 - path: '/activities/[^/]+/signup', 23 + path: '/activities/[^/]+/signup',
23 - regex: true, 24 + regex: true,
24 - }, 25 + },
25 - { 26 + {
26 - path: '/checkin', 27 + path: '/checkin',
27 - exact: false, 28 + exact: false,
28 - }, 29 + },
29 - { 30 + {
30 - path: '/teacher', 31 + path: '/teacher',
31 - exact: false, 32 + exact: false,
32 - }, 33 + },
33 - { 34 + {
34 - path: '/studyDetail', 35 + path: '/studyDetail',
35 - exact: false, 36 + exact: false,
36 - }, 37 + },
37 ] 38 ]
38 39
39 /** 40 /**
...@@ -41,17 +42,17 @@ export const authRequiredRoutes = [ ...@@ -41,17 +42,17 @@ export const authRequiredRoutes = [
41 * @returns {boolean} 始终返回true,不在路由守卫内自动触发授权 42 * @returns {boolean} 始终返回true,不在路由守卫内自动触发授权
42 */ 43 */
43 export const checkWxAuth = async () => { 44 export const checkWxAuth = async () => {
44 - // 说明:根据新业务需求,微信授权不应在路由守卫中自动触发 45 + // 说明:根据新业务需求,微信授权不应在路由守卫中自动触发
45 - // 此函数保留以兼容旧代码调用,但不再进行重定向 46 + // 此函数保留以兼容旧代码调用,但不再进行重定向
46 - try { 47 + try {
47 - if (!import.meta.env.DEV && wxInfo().isWeiXin) { 48 + if (!import.meta.env.DEV && wxInfo().isWeiXin) {
48 - // 仅做一次授权状态探测,避免无意义请求 49 + // 仅做一次授权状态探测,避免无意义请求
49 - await getAuthInfoAPI(); 50 + await getAuthInfoAPI()
50 - }
51 - } catch (error) {
52 - // 忽略授权探测错误,不影响后续流程
53 } 51 }
54 - return true; 52 + } catch (error) {
53 + // 忽略授权探测错误,不影响后续流程
54 + }
55 + return true
55 } 56 }
56 57
57 /** 58 /**
...@@ -64,32 +65,36 @@ export const checkWxAuth = async () => { ...@@ -64,32 +65,36 @@ export const checkWxAuth = async () => {
64 * @returns {void} 65 * @returns {void}
65 */ 66 */
66 export const startWxAuth = async () => { 67 export const startWxAuth = async () => {
67 - // 开发环境不触发微信授权 68 + // 开发环境不触发微信授权
68 - if (import.meta.env.DEV) { 69 + if (import.meta.env.DEV) {
69 - // 开发环境下不触发微信授权登录 70 + // 开发环境下不触发微信授权登录
70 - return; 71 + return
72 + }
73 +
74 + const info = wxInfo()
75 + // 非微信环境不进行授权跳转
76 + if (!info.isWeiXin) {
77 + return
78 + }
79 +
80 + // 如果已授权则不跳转;否则进入授权页
81 + try {
82 + const { code, data } = await getAuthInfoAPI()
83 + if (code && data.openid_has) {
84 + return
71 } 85 }
72 - 86 + } catch (e) {
73 - const info = wxInfo(); 87 + // 探测失败不影响授权流程,继续跳转
74 - // 非微信环境不进行授权跳转 88 + }
75 - if (!info.isWeiXin) { 89 +
76 - return; 90 + // 跳转到微信授权地址。
77 - } 91 + // OAuth 回跳链路对 hash 路由并不稳定,这里显式拆分 base_url 与 ret_hash,
78 - 92 + // 由前端在 main.js 安装路由前进行一次 hash 复原。
79 - // 如果已授权则不跳转;否则进入授权页 93 + const short_url = buildOAuthAuthorizeUrl(window.location.href)
80 - try { 94 + if (!short_url) {
81 - const { code, data } = await getAuthInfoAPI(); 95 + return
82 - if (code && data.openid_has) { 96 + }
83 - return; 97 + location.href = short_url
84 - }
85 - } catch (e) {
86 - // 探测失败不影响授权流程,继续跳转
87 - }
88 -
89 - // 跳转到微信授权地址
90 - const raw_url = encodeURIComponent(location.href);
91 - const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`;
92 - location.href = short_url;
93 } 98 }
94 99
95 // 首次访问标志 100 // 首次访问标志
...@@ -100,9 +105,7 @@ const WELCOME_VISITED_AT = 'welcome_visited_at' ...@@ -100,9 +105,7 @@ const WELCOME_VISITED_AT = 'welcome_visited_at'
100 * @description 检查用户是否已访问过欢迎页 105 * @description 检查用户是否已访问过欢迎页
101 * @returns {boolean} 106 * @returns {boolean}
102 */ 107 */
103 -export const hasVisitedWelcome = () => { 108 +export const hasVisitedWelcome = () => localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
104 - return localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
105 -}
106 109
107 /** 110 /**
108 * @description 标记用户已访问欢迎页 111 * @description 标记用户已访问欢迎页
...@@ -124,42 +127,41 @@ export const resetWelcomeFlag = () => { ...@@ -124,42 +127,41 @@ export const resetWelcomeFlag = () => {
124 127
125 // 检查用户是否已登录 128 // 检查用户是否已登录
126 129
127 -
128 /** 130 /**
129 * @description 登录权限检查,未登录时重定向到登录页 131 * @description 登录权限检查,未登录时重定向到登录页
130 * @param {*} to 目标路由对象 132 * @param {*} to 目标路由对象
131 * @returns {true|Object} 允许通过或返回重定向对象 133 * @returns {true|Object} 允许通过或返回重定向对象
132 */ 134 */
133 -export const checkAuth = (to) => { 135 +export const checkAuth = to => {
134 - const currentUser = JSON.parse(localStorage.getItem('currentUser')) 136 + const currentUser = JSON.parse(localStorage.getItem('currentUser'))
135 - 137 +
136 - // 检查当前路由是否需要认证 138 + // 检查当前路由是否需要认证
137 - // 方式一:白名单匹配(兼容旧逻辑) 139 + // 方式一:白名单匹配(兼容旧逻辑)
138 - const needAuthByList = authRequiredRoutes.some((route) => { 140 + const needAuthByList = authRequiredRoutes.some(route => {
139 - // 如果是正则匹配模式 141 + // 如果是正则匹配模式
140 - if (route.regex) { 142 + if (route.regex) {
141 - return new RegExp(`^${route.path}$`).test(to.path) 143 + return new RegExp(`^${route.path}$`).test(to.path)
142 - }
143 - // 如果是精确匹配模式
144 - if (route.exact) {
145 - return to.path === route.path
146 - }
147 - // 默认前缀匹配模式
148 - return to.path.startsWith(route.path)
149 - })
150 - // 方式二:读取路由元信息 requiresAuth(推荐)
151 - // 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性
152 - // 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配
153 - const needAuthByMeta = to.matched
154 - ? to.matched.some(record => record.meta && record.meta.requiresAuth === true)
155 - : false;
156 -
157 - const needAuth = needAuthByList || needAuthByMeta
158 -
159 - if (needAuth && !currentUser) {
160 - // 未登录时重定向到登录页面
161 - return { path: '/login', query: { redirect: to.fullPath } }
162 } 144 }
163 - 145 + // 如果是精确匹配模式
164 - return true 146 + if (route.exact) {
147 + return to.path === route.path
148 + }
149 + // 默认前缀匹配模式
150 + return to.path.startsWith(route.path)
151 + })
152 + // 方式二:读取路由元信息 requiresAuth(推荐)
153 + // 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性
154 + // 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配
155 + const needAuthByMeta = to.matched
156 + ? to.matched.some(record => record.meta && record.meta.requiresAuth === true)
157 + : false
158 +
159 + const needAuth = needAuthByList || needAuthByMeta
160 +
161 + if (needAuth && !currentUser) {
162 + // 未登录时重定向到登录页面
163 + return { path: '/login', query: { redirect: to.fullPath } }
164 + }
165 +
166 + return true
165 } 167 }
......
1 +import { describe, expect, it } from 'vitest'
2 +
3 +import { buildOAuthAuthorizeUrl, getOAuthRestoredUrl } from '../oauthHashRestore'
4 +
5 +describe('oauthHashRestore', () => {
6 + it('should build oauth authorize url with split base and hash', () => {
7 + const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321?from_course_id=2995248'
8 +
9 + expect(buildOAuthAuthorizeUrl(href)).toBe(
10 + '/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248'
11 + )
12 + })
13 +
14 + it('should append test_openid in dev mode', () => {
15 + const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321'
16 +
17 + expect(buildOAuthAuthorizeUrl(href, { is_dev: true, test_openid: 'abc123' })).toBe(
18 + '/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321&test_openid=abc123'
19 + )
20 + })
21 +
22 + it('should restore ret_hash when current url has no hash', () => {
23 + const href =
24 + 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1&ret_hash=%23%2Flogin%3Fredirect%3D%252FstudyDetail%252F3552321%253Ffrom_course_id%253D2995248'
25 +
26 + expect(getOAuthRestoredUrl(href)).toBe(
27 + 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1#/login?redirect=%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248'
28 + )
29 + })
30 +
31 + it('should not override an existing hash', () => {
32 + const href =
33 + 'https://wxm.behalo.cc/f/mlaj/?ret_hash=%23%2FstudyDetail%2F3552321#/checkin/index?id=3189684'
34 +
35 + expect(getOAuthRestoredUrl(href)).toBeNull()
36 + })
37 +
38 + it('should return null when ret_hash is missing', () => {
39 + const href = 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1'
40 +
41 + expect(getOAuthRestoredUrl(href)).toBeNull()
42 + })
43 +
44 + it('should return null for invalid authorize target', () => {
45 + expect(buildOAuthAuthorizeUrl('')).toBeNull()
46 + })
47 +})
1 +/**
2 + * @description 从 OAuth 回跳 URL 中恢复 hash 路由。
3 + * 某些回跳链路只会稳定保留 base URL,因此需要借助 ret_hash 在前端复原原始 hash。
4 + * @param {string} href 当前完整地址
5 + * @returns {string|null} 需要替换的新地址;无需处理时返回 null
6 + */
7 +export const getOAuthRestoredUrl = href => {
8 + if (!href || typeof href !== 'string') return null
9 +
10 + let url = null
11 + try {
12 + url = new URL(href)
13 + } catch (error) {
14 + return null
15 + }
16 +
17 + const ret_hash = url.searchParams.get('ret_hash')
18 + if (!ret_hash || url.hash) return null
19 +
20 + url.searchParams.delete('ret_hash')
21 + const base = url.toString().split('#')[0]
22 + return `${base}${ret_hash}`
23 +}
24 +
25 +/**
26 + * @description 构造微信 OAuth 授权地址,显式拆分 base_url 与 hash。
27 + * @param {string} target_href 授权前的目标地址
28 + * @param {{ is_dev?: boolean, test_openid?: string }} [options] 额外选项
29 + * @returns {string|null} 可直接跳转的授权地址;输入非法时返回 null
30 + */
31 +export const buildOAuthAuthorizeUrl = (target_href, options = {}) => {
32 + if (!target_href || typeof target_href !== 'string') return null
33 +
34 + const [base_part, ...hash_parts] = target_href.split('#')
35 + if (!base_part) return null
36 +
37 + const ret_hash = hash_parts.length ? `#${hash_parts.join('#')}` : ''
38 + const base_url = encodeURIComponent(base_part)
39 + const encoded_hash = encodeURIComponent(ret_hash)
40 +
41 + let short_url = `/srv/?f=behalo&a=openid&res=${base_url}&ret_hash=${encoded_hash}`
42 + if (options.is_dev && options.test_openid) {
43 + short_url += `&test_openid=${encodeURIComponent(options.test_openid)}`
44 + }
45 +
46 + return short_url
47 +}
48 +
49 +/**
50 + * @description 在应用初始化前尝试恢复 OAuth 回跳丢失的 hash。
51 + * @returns {boolean} 是否发生了地址恢复
52 + */
53 +export const restoreHashAfterOAuth = () => {
54 + if (typeof window === 'undefined' || typeof window.location === 'undefined') {
55 + return false
56 + }
57 +
58 + const next_url = getOAuthRestoredUrl(window.location.href)
59 + if (!next_url) return false
60 +
61 + window.history.replaceState(null, '', next_url)
62 + return true
63 +}
...@@ -12,8 +12,9 @@ ...@@ -12,8 +12,9 @@
12 <script setup> 12 <script setup>
13 import { onMounted } from 'vue' 13 import { onMounted } from 'vue'
14 import { useRoute } from 'vue-router' 14 import { useRoute } from 'vue-router'
15 +import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore'
15 16
16 -const $route = useRoute(); 17 +const $route = useRoute()
17 18
18 onMounted(() => { 19 onMounted(() => {
19 // php需要先跳转链接获取openid 20 // php需要先跳转链接获取openid
...@@ -22,11 +23,12 @@ onMounted(() => { ...@@ -22,11 +23,12 @@ onMounted(() => {
22 * 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。 23 * 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。
23 * 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。 24 * 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。
24 */ 25 */
25 - let raw_url = encodeURIComponent(location.origin + location.pathname + $route.query.href); // 未授权的地址 26 + const target_href = `${location.origin}${location.pathname}${String($route.query.href || '')}`
26 - // TAG: 开发环境测试数据 27 + const short_url = buildOAuthAuthorizeUrl(target_href, {
27 - const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`; 28 + is_dev: import.meta.env.DEV,
28 - location.href = import.meta.env.DEV 29 + test_openid: import.meta.env.VITE_OPENID,
29 - ? `${short_url}&test_openid=${import.meta.env.VITE_OPENID}` 30 + })
30 - : `${short_url}`; 31 + if (!short_url) return
32 + location.href = short_url
31 }) 33 })
32 </script> 34 </script>
......