hookehuyr

fix(auth): restore hash route after oauth redirect

......@@ -9,9 +9,10 @@ import { createApp } from 'vue'
import './style.css'
import App from './App.vue'
import router from './router'
import axios from '@/utils/axios';
import axios from '@/utils/axios'
import { restoreHashAfterOAuth } from '@/utils/oauthHashRestore'
import 'vant/lib/index.css'
import '@vant/touch-emulator';
import '@vant/touch-emulator'
/* import the fontawesome core */
import { library } from '@fortawesome/fontawesome-svg-core'
......@@ -19,47 +20,80 @@ import { library } from '@fortawesome/fontawesome-svg-core'
import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'
import { Icon as IconifyIcon } from '@iconify/vue'
/* import specific icons */
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'
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'
/* add icons to the library */
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 )
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
)
if (!Array.prototype.at) {
Array.prototype.at = function(n) {
n = Math.trunc(n) || 0;
if (n < 0) n += this.length;
if (n < 0 || n >= this.length) return undefined;
return this[n];
};
Array.prototype.at = function (n) {
n = Math.trunc(n) || 0
if (n < 0) n += this.length
if (n < 0 || n >= this.length) return undefined
return this[n]
}
}
const app = createApp(App)
app.component('Icon', IconifyIcon)
app.component('font-awesome-icon', FontAwesomeIcon)
app.component('FontAwesomeIcon', FontAwesomeIcon)
// 屏蔽警告信息
app.config.warnHandler = () => null;
app.config.globalProperties.$http = axios; // 关键语句
/**
* @function restoreHashAfterOAuth
* @description 前端复原 OAuth 回跳的 hash 路由位置:当 URL 中存在 ret_hash 参数且当前无 hash 时,将其拼回地址栏。
* @returns {void}
*/
// function restoreHashAfterOAuth() {
// const url = new URL(window.location.href);
// const ret_hash = url.searchParams.get('ret_hash');
// if (ret_hash && !window.location.hash) {
// // 删除 ret_hash,保留其他查询参数
// url.searchParams.delete('ret_hash');
// const base = url.toString().split('#')[0];
// const new_url = base + ret_hash;
// // 使用 replaceState 避免再次刷新与历史记录污染
// window.history.replaceState(null, '', new_url);
// }
// }
// void restoreHashAfterOAuth
app.config.warnHandler = () => null
app.config.globalProperties.$http = axios // 关键语句
// 在安装路由前进行一次 hash 复原,确保初始路由正确
// restoreHashAfterOAuth()
restoreHashAfterOAuth()
app.use(router)
// 开发环境添加欢迎页调试工具
......
......@@ -6,34 +6,35 @@
* @Description: 路由守卫逻辑
*/
import { getAuthInfoAPI } from '@/api/auth'
import { wxInfo } from "@/utils/tools"
import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore'
import { wxInfo } from '@/utils/tools'
// TAG: 需要登录才能访问的路由
export const authRequiredRoutes = [
{
path: '/profile',
exact: false,
},
{
path: '/checkout',
exact: true,
},
{
path: '/activities/[^/]+/signup',
regex: true,
},
{
path: '/checkin',
exact: false,
},
{
path: '/teacher',
exact: false,
},
{
path: '/studyDetail',
exact: false,
},
{
path: '/profile',
exact: false,
},
{
path: '/checkout',
exact: true,
},
{
path: '/activities/[^/]+/signup',
regex: true,
},
{
path: '/checkin',
exact: false,
},
{
path: '/teacher',
exact: false,
},
{
path: '/studyDetail',
exact: false,
},
]
/**
......@@ -41,17 +42,17 @@ export const authRequiredRoutes = [
* @returns {boolean} 始终返回true,不在路由守卫内自动触发授权
*/
export const checkWxAuth = async () => {
// 说明:根据新业务需求,微信授权不应在路由守卫中自动触发
// 此函数保留以兼容旧代码调用,但不再进行重定向
try {
if (!import.meta.env.DEV && wxInfo().isWeiXin) {
// 仅做一次授权状态探测,避免无意义请求
await getAuthInfoAPI();
}
} catch (error) {
// 忽略授权探测错误,不影响后续流程
// 说明:根据新业务需求,微信授权不应在路由守卫中自动触发
// 此函数保留以兼容旧代码调用,但不再进行重定向
try {
if (!import.meta.env.DEV && wxInfo().isWeiXin) {
// 仅做一次授权状态探测,避免无意义请求
await getAuthInfoAPI()
}
return true;
} catch (error) {
// 忽略授权探测错误,不影响后续流程
}
return true
}
/**
......@@ -64,32 +65,36 @@ export const checkWxAuth = async () => {
* @returns {void}
*/
export const startWxAuth = async () => {
// 开发环境不触发微信授权
if (import.meta.env.DEV) {
// 开发环境下不触发微信授权登录
return;
// 开发环境不触发微信授权
if (import.meta.env.DEV) {
// 开发环境下不触发微信授权登录
return
}
const info = wxInfo()
// 非微信环境不进行授权跳转
if (!info.isWeiXin) {
return
}
// 如果已授权则不跳转;否则进入授权页
try {
const { code, data } = await getAuthInfoAPI()
if (code && data.openid_has) {
return
}
const info = wxInfo();
// 非微信环境不进行授权跳转
if (!info.isWeiXin) {
return;
}
// 如果已授权则不跳转;否则进入授权页
try {
const { code, data } = await getAuthInfoAPI();
if (code && data.openid_has) {
return;
}
} catch (e) {
// 探测失败不影响授权流程,继续跳转
}
// 跳转到微信授权地址
const raw_url = encodeURIComponent(location.href);
const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`;
location.href = short_url;
} catch (e) {
// 探测失败不影响授权流程,继续跳转
}
// 跳转到微信授权地址。
// OAuth 回跳链路对 hash 路由并不稳定,这里显式拆分 base_url 与 ret_hash,
// 由前端在 main.js 安装路由前进行一次 hash 复原。
const short_url = buildOAuthAuthorizeUrl(window.location.href)
if (!short_url) {
return
}
location.href = short_url
}
// 首次访问标志
......@@ -100,9 +105,7 @@ const WELCOME_VISITED_AT = 'welcome_visited_at'
* @description 检查用户是否已访问过欢迎页
* @returns {boolean}
*/
export const hasVisitedWelcome = () => {
return localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
}
export const hasVisitedWelcome = () => localStorage.getItem(HAS_VISITED_WELCOME) === 'true'
/**
* @description 标记用户已访问欢迎页
......@@ -124,42 +127,41 @@ export const resetWelcomeFlag = () => {
// 检查用户是否已登录
/**
* @description 登录权限检查,未登录时重定向到登录页
* @param {*} to 目标路由对象
* @returns {true|Object} 允许通过或返回重定向对象
*/
export const checkAuth = (to) => {
const currentUser = JSON.parse(localStorage.getItem('currentUser'))
// 检查当前路由是否需要认证
// 方式一:白名单匹配(兼容旧逻辑)
const needAuthByList = authRequiredRoutes.some((route) => {
// 如果是正则匹配模式
if (route.regex) {
return new RegExp(`^${route.path}$`).test(to.path)
}
// 如果是精确匹配模式
if (route.exact) {
return to.path === route.path
}
// 默认前缀匹配模式
return to.path.startsWith(route.path)
})
// 方式二:读取路由元信息 requiresAuth(推荐)
// 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性
// 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配
const needAuthByMeta = to.matched
? to.matched.some(record => record.meta && record.meta.requiresAuth === true)
: false;
const needAuth = needAuthByList || needAuthByMeta
if (needAuth && !currentUser) {
// 未登录时重定向到登录页面
return { path: '/login', query: { redirect: to.fullPath } }
export const checkAuth = to => {
const currentUser = JSON.parse(localStorage.getItem('currentUser'))
// 检查当前路由是否需要认证
// 方式一:白名单匹配(兼容旧逻辑)
const needAuthByList = authRequiredRoutes.some(route => {
// 如果是正则匹配模式
if (route.regex) {
return new RegExp(`^${route.path}$`).test(to.path)
}
return true
// 如果是精确匹配模式
if (route.exact) {
return to.path === route.path
}
// 默认前缀匹配模式
return to.path.startsWith(route.path)
})
// 方式二:读取路由元信息 requiresAuth(推荐)
// 注意:axios 拦截器中调用时,to 可能不是完整的 Route 对象,可能缺少 matched 属性
// 如果没有 matched 属性,则回退到仅依赖 path 的白名单匹配
const needAuthByMeta = to.matched
? to.matched.some(record => record.meta && record.meta.requiresAuth === true)
: false
const needAuth = needAuthByList || needAuthByMeta
if (needAuth && !currentUser) {
// 未登录时重定向到登录页面
return { path: '/login', query: { redirect: to.fullPath } }
}
return true
}
......
import { describe, expect, it } from 'vitest'
import { buildOAuthAuthorizeUrl, getOAuthRestoredUrl } from '../oauthHashRestore'
describe('oauthHashRestore', () => {
it('should build oauth authorize url with split base and hash', () => {
const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321?from_course_id=2995248'
expect(buildOAuthAuthorizeUrl(href)).toBe(
'/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248'
)
})
it('should append test_openid in dev mode', () => {
const href = 'https://wxm.behalo.cc/f/mlaj/#/studyDetail/3552321'
expect(buildOAuthAuthorizeUrl(href, { is_dev: true, test_openid: 'abc123' })).toBe(
'/srv/?f=behalo&a=openid&res=https%3A%2F%2Fwxm.behalo.cc%2Ff%2Fmlaj%2F&ret_hash=%23%2FstudyDetail%2F3552321&test_openid=abc123'
)
})
it('should restore ret_hash when current url has no hash', () => {
const href =
'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1&ret_hash=%23%2Flogin%3Fredirect%3D%252FstudyDetail%252F3552321%253Ffrom_course_id%253D2995248'
expect(getOAuthRestoredUrl(href)).toBe(
'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1#/login?redirect=%2FstudyDetail%2F3552321%3Ffrom_course_id%3D2995248'
)
})
it('should not override an existing hash', () => {
const href =
'https://wxm.behalo.cc/f/mlaj/?ret_hash=%23%2FstudyDetail%2F3552321#/checkin/index?id=3189684'
expect(getOAuthRestoredUrl(href)).toBeNull()
})
it('should return null when ret_hash is missing', () => {
const href = 'https://wxm.behalo.cc/f/mlaj/?code=abc&state=1'
expect(getOAuthRestoredUrl(href)).toBeNull()
})
it('should return null for invalid authorize target', () => {
expect(buildOAuthAuthorizeUrl('')).toBeNull()
})
})
/**
* @description 从 OAuth 回跳 URL 中恢复 hash 路由。
* 某些回跳链路只会稳定保留 base URL,因此需要借助 ret_hash 在前端复原原始 hash。
* @param {string} href 当前完整地址
* @returns {string|null} 需要替换的新地址;无需处理时返回 null
*/
export const getOAuthRestoredUrl = href => {
if (!href || typeof href !== 'string') return null
let url = null
try {
url = new URL(href)
} catch (error) {
return null
}
const ret_hash = url.searchParams.get('ret_hash')
if (!ret_hash || url.hash) return null
url.searchParams.delete('ret_hash')
const base = url.toString().split('#')[0]
return `${base}${ret_hash}`
}
/**
* @description 构造微信 OAuth 授权地址,显式拆分 base_url 与 hash。
* @param {string} target_href 授权前的目标地址
* @param {{ is_dev?: boolean, test_openid?: string }} [options] 额外选项
* @returns {string|null} 可直接跳转的授权地址;输入非法时返回 null
*/
export const buildOAuthAuthorizeUrl = (target_href, options = {}) => {
if (!target_href || typeof target_href !== 'string') return null
const [base_part, ...hash_parts] = target_href.split('#')
if (!base_part) return null
const ret_hash = hash_parts.length ? `#${hash_parts.join('#')}` : ''
const base_url = encodeURIComponent(base_part)
const encoded_hash = encodeURIComponent(ret_hash)
let short_url = `/srv/?f=behalo&a=openid&res=${base_url}&ret_hash=${encoded_hash}`
if (options.is_dev && options.test_openid) {
short_url += `&test_openid=${encodeURIComponent(options.test_openid)}`
}
return short_url
}
/**
* @description 在应用初始化前尝试恢复 OAuth 回跳丢失的 hash。
* @returns {boolean} 是否发生了地址恢复
*/
export const restoreHashAfterOAuth = () => {
if (typeof window === 'undefined' || typeof window.location === 'undefined') {
return false
}
const next_url = getOAuthRestoredUrl(window.location.href)
if (!next_url) return false
window.history.replaceState(null, '', next_url)
return true
}
......@@ -12,8 +12,9 @@
<script setup>
import { onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { buildOAuthAuthorizeUrl } from '@/utils/oauthHashRestore'
const $route = useRoute();
const $route = useRoute()
onMounted(() => {
// php需要先跳转链接获取openid
......@@ -22,11 +23,12 @@ onMounted(() => {
* 该方法不会对 ASCII 字母和数字进行编码,也不会对这些 ASCII 标点符号进行编码: - _ . ! ~ * ' ( ) 。
* 其他字符(比如 :;/?:@&=+$,# 这些用于分隔 URI 组件的标点符号),都是由一个或多个十六进制的转义序列替换的。
*/
let raw_url = encodeURIComponent(location.origin + location.pathname + $route.query.href); // 未授权的地址
// TAG: 开发环境测试数据
const short_url = `/srv/?f=behalo&a=openid&res=${raw_url}`;
location.href = import.meta.env.DEV
? `${short_url}&test_openid=${import.meta.env.VITE_OPENID}`
: `${short_url}`;
const target_href = `${location.origin}${location.pathname}${String($route.query.href || '')}`
const short_url = buildOAuthAuthorizeUrl(target_href, {
is_dev: import.meta.env.DEV,
test_openid: import.meta.env.VITE_OPENID,
})
if (!short_url) return
location.href = short_url
})
</script>
......