纲要
- 前端安全的本质与局限性
- 按钮级权限控制实现
- 从JWT中提取权限列表
- 权限检查函数的设计
- 在模板中禁用按钮
- 路由守卫与页面级访问控制
- 路由元信息定义所需权限
- 全局前置守卫的实现
- 动态路由方案概述
- 后端支撑:Spring Security + OAuth2 的权限传递
- 在JWT Token中携带Authority
- 资源服务器配置示例
- 设计权衡与最佳实践
前端安全概述
前端安全本质上是通过角色或权限来限制用户进入某个界面,或者隐藏、禁用页面上的特定元素。它是对用户友好度的一种提升,但绝不能替代后端安全。攻击者完全可以直接调用API绕过前端限制,因此真正的安全屏障必须构建在后端。本文聚焦于如何在实际项目中实现按钮级安全与路由守卫,结合 Spring Security + OAuth2 提供的权限信息,打造前后端协同的权限控制体系。
权限数据来源:解析JWT
在OAuth2体系中,用户登录后获得由授权服务器签发的JWT,其Payload中通常会包含用户的权限列表。例如我们可以在生成令牌时添加 authorities 字段:
java
// Spring Security OAuth2 令牌增强器配置
public class CustomTokenEnhancer implements TokenEnhancer {
@Override
public OAuth2AccessToken enhance(OAuth2AccessToken accessToken, OAuth2Authentication authentication) {
Map<String, Object> additionalInfo = new HashMap<>();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
List<String> authorityStrings = authorities.stream()
.map(GrantedAuthority::getAuthority)
.collect(Collectors.toList());
additionalInfo.put("authorities", authorityStrings);
((DefaultOAuth2AccessToken) accessToken).setAdditionalInformation(additionalInfo);
return accessToken;
}
}
前端获取Token后,通过解析JWT的Payload部分即可得到权限数组。浏览器端可以使用 jwt-decode 或自行解析:
ts
import { Buffer } from 'buffer';
function parseJwt(token: string): any {
const parts = token.split('.');
if (parts.length !== 3) throw new Error('Invalid JWT');
const payload = parts[1];
const decoded = Buffer.from(payload, 'base64').toString('utf-8');
return JSON.parse(decoded);
}
// 从Token中提取权限列表
function getAuthorities(): string[] {
const token = localStorage.getItem('access_token') || '';
const jwtPayload = parseJwt(token);
return jwtPayload.authorities || [];
}
按钮级权限控制
按钮级控制的核心是根据用户拥有的权限决定是否禁用某个操作元素。为了实现高度解耦,我们编写一个权限检查函数 hasPermission,它接收一个或多个权限标识,判断当前用户是否具备其中任意一个。
ts
// utils/permission.ts
let authorities: string[] = [];
export function setAuthorities(list: string[]) {
authorities = list;
}
export function hasPermission(...required: string[]): boolean {
if (!authorities.length) return false;
return required.some(r => authorities.includes(r));
}
在Vue组件中,我们可以通过 v-bind:disabled 或自定义指令来控制按钮状态。例如,用户管理列表中,禁止用户对自己执行"禁用"或"分配角色"操作,且需要具备 update:user 权限才可操作:
html
<template>
<a-button
:disabled="!hasUpdatePermission || isSelf"
@click="handleDisable(record)"
>
{{ record.status === 'active' ? '禁用' : '启用' }}
</a-button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { hasPermission } from '@/utils/permission';
import { useUserStore } from '@/store/user';
const props = defineProps<{ record: User }>();
const userStore = useUserStore();
const hasUpdatePermission = computed(() => hasPermission('update:user'));
const isSelf = computed(() => userStore.username === props.record.username);
</script>
表达式中,hasUpdatePermission 判断是否持有 update:user 原子权限;isSelf 通过比对当前登录用户名与行记录的用户名,实现"自己不能操作自己"的业务规则。两个条件任一成立,按钮即被禁用。
路由守卫:页面级访问控制
当需要阻止用户访问整个页面时,可以利用前端路由守卫。以Vue Router为例,首先在路由定义的 meta 字段中声明该页面所需的权限:
ts
// router/index.ts
const routes = [
{
path: '/users',
name: 'UserManagement',
component: () => import('@/views/users/List.vue'),
meta: {
requiresAuth: true,
permissions: ['view:user', 'admin']
}
}
];
然后注册全局前置守卫,在每次路由跳转前检查权限。守卫从JWT中获取权限列表(已事先存入状态管理或全局变量),并检查目标路由的 meta.permissions:
ts
// router/guard.ts
import { getAuthorities, hasPermission } from '@/utils/permission';
import type { Router } from 'vue-router';
export function setupGuard(router: Router) {
router.beforeEach((to, from, next) => {
const authorities = getAuthorities();
setAuthorities(authorities); // 每次刷新令牌时同步
const requiredPermissions = to.meta.permissions as string[] | undefined;
if (requiredPermissions && requiredPermissions.length > 0) {
const canAccess = hasPermission(...requiredPermissions);
if (!canAccess) {
// 跳转到403或首页
return next('/unauthorized');
}
}
next();
});
}
这种方式将权限规则与路由定义绑定在一起,清晰且易于维护。如需动态配置,可以将路由-权限映射关系存储在后端数据库,前端在守卫中请求该映射表,再与当前用户权限进行比对,逻辑完全一致。
后端API 权限状态 路由守卫 浏览器 后端API 权限状态 路由守卫 浏览器 #mermaid-svg-ByFzqnbmsc6jA0M0{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ByFzqnbmsc6jA0M0 .error-icon{fill:#552222;}#mermaid-svg-ByFzqnbmsc6jA0M0 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ByFzqnbmsc6jA0M0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ByFzqnbmsc6jA0M0 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ByFzqnbmsc6jA0M0 .marker.cross{stroke:#333333;}#mermaid-svg-ByFzqnbmsc6jA0M0 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ByFzqnbmsc6jA0M0 p{margin:0;}#mermaid-svg-ByFzqnbmsc6jA0M0 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ByFzqnbmsc6jA0M0 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ByFzqnbmsc6jA0M0 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-ByFzqnbmsc6jA0M0 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-ByFzqnbmsc6jA0M0 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-ByFzqnbmsc6jA0M0 .sequenceNumber{fill:white;}#mermaid-svg-ByFzqnbmsc6jA0M0 #sequencenumber{fill:#333;}#mermaid-svg-ByFzqnbmsc6jA0M0 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-ByFzqnbmsc6jA0M0 .messageText{fill:#333;stroke:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ByFzqnbmsc6jA0M0 .labelText,#mermaid-svg-ByFzqnbmsc6jA0M0 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .loopText,#mermaid-svg-ByFzqnbmsc6jA0M0 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-ByFzqnbmsc6jA0M0 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-ByFzqnbmsc6jA0M0 .noteText,#mermaid-svg-ByFzqnbmsc6jA0M0 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-ByFzqnbmsc6jA0M0 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ByFzqnbmsc6jA0M0 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ByFzqnbmsc6jA0M0 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-ByFzqnbmsc6jA0M0 .actorPopupMenu{position:absolute;}#mermaid-svg-ByFzqnbmsc6jA0M0 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-ByFzqnbmsc6jA0M0 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-ByFzqnbmsc6jA0M0 .actor-man circle,#mermaid-svg-ByFzqnbmsc6jA0M0 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-ByFzqnbmsc6jA0M0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt权限列表为空或过期 alt拥有所需权限权限不足 导航到 /users获取当前用户权限列表请求用户权限或Token返回权限数组更新权限状态检查目标路由 meta.permissions允许访问重定向至 /unauthorized
动态路由的进一步思考
有些场景下需要完全从后端加载可访问的路由表,这样可以做到更灵活的权限控制。可以在用户登录成功后,后端返回该角色可访问的路由路径列表及对应的组件映射,前端动态注册路由。但这样做会增加维护成本:每次前端路由结构调整,都需要同步修改后端数据。除非有极强的动态配置需求,一般建议将路由结构写在前端,仅将权限标识作为元数据,后端只提供用户的权限列表。
后端资源服务器配置示例
为了让上述方案完整运行,Spring Security 资源服务器需正确解析JWT并提取权限。以下配置将JWT中的 authorities 声明映射为 GrantedAuthority:
java
@Configuration
@EnableResourceServer
public class ResourceServerConfig extends ResourceServerConfigurerAdapter {
@Override
public void configure(ResourceServerSecurityConfigurer resources) {
resources.resourceId("my-resource");
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/api/admin/**").hasAuthority("admin")
.antMatchers("/api/users/**").hasAnyAuthority("view:user", "update:user")
.anyRequest().authenticated();
}
}
同时,自定义 JwtAccessTokenConverter 以正确读取自定义claims:
java
@Bean
public JwtAccessTokenConverter accessTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("secret");
converter.setAccessTokenConverter(new DefaultAccessTokenConverter() {
@Override
public OAuth2Authentication extractAuthentication(Map<String, ?> claims) {
OAuth2Authentication authentication = super.extractAuthentication(claims);
@SuppressWarnings("unchecked")
List<String> authoritiesList = (List<String>) claims.get("authorities");
if (authoritiesList != null) {
List<GrantedAuthority> grantedAuthorities = authoritiesList.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return new OAuth2Authentication(authentication.getOAuth2Request(),
new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), "N/A", grantedAuthorities));
}
return authentication;
}
});
return converter;
}
这样后端便可以根据权限注解 @PreAuthorize("hasAuthority('update:user')") 进行细粒度控制。
总结
前端安全是用户体验的重要补充,通过在按钮和路由层面进行控制,可以避免将无权限操作暴露给用户。真正安全由后端保障,两者协同才能构建可靠的应用。
本文演示了如何在Spring Security + OAuth2体系中,将权限信息通过JWT传递到前端,并利用简洁的权限检查函数实现按钮禁用和路由守卫,方案直接可落地。
实践中需根据项目迭代特点权衡硬编码与数据库动态配置的利弊,通常将权限标识内置于代码中的方式更易于维护。