0.概述
0.1 整体架构概述
这个RBAC权限系统基于Spring Security和Token认证机制,主要包含以下核心组件:
-
用户-角色-菜单的多对多关系模型
-
基于Token的认证流程
-
细粒度的权限控制(菜单权限、按钮权限)
-
灵活的权限配置方式
1.用户-角色-菜单的多对多关系模型功能权限
1.1功能权限5张表设计
system_user 表
意义:用于存储系统中用户的基本信息,是系统识别和管理用户的基础。它涵盖了用户身份验证、个人信息、组织归属、操作记录等多方面信息,是用户在系统内的数字化档案。在用户登录、权限分配、数据访问控制等业务流程中,都依赖此表中的数据。
#system_user 表
id:用户唯一标识,用于区分不同用户记录,方便在系统中进行数据关联、检索、更新等操作 。
username:用户登录名,是用户登录系统的凭证之一 。
password:用户登录密码,经过加密存储,用于验证用户身份 。
nickname:用户昵称,可用于展示在系统前端,方便用户识别和称呼 。
remark:备注信息,用于记录关于该用户的额外说明,如特殊权限原因、岗位描述等 。
dept_id:用户所属部门 ID,关联部门表,用于确定用户所在组织架构位置,方便进行部门级权限控制和数据统计 。
post_ids:用户所担任岗位 ID 集合(可能以逗号分隔等形式存储多个岗位 ID ),用于岗位相关权限和职责分配 。
email:用户电子邮箱,可用于找回密码、接收系统通知等 。
mobile:用户手机号码,用于身份验证(如短信验证码登录、找回密码 )、接收通知等。
sex:用户性别标识,用于统计、个性化展示等 。
avatar:用户头像地址,用于前端展示用户形象 。
status:用户状态(如启用、禁用 ),控制用户能否登录和使用系统功能。
login_ip:用户最后一次登录的 IP 地址,用于安全审计,追踪登录来源 。
login_date:用户最后一次登录时间,用于分析用户登录频率、活跃情况等 。
creator:创建该用户记录的操作人 ,用于记录操作日志和追溯创建来源。
create_time:用户记录创建时间,用于数据审计和分析 。
updater:最后更新该用户记录的操作人 ,便于记录操作历史。
update_time:用户记录最后更新时间,用于版本管理和数据变更追溯 。
deleted:逻辑删除标识(0 表示未删除,1 表示已删除 ),方便数据归档和恢复,不真正从数据库删除记录。
tenant_id:多租户标识,在多租户系统中,用于区分不同租户下的用户数据 。
system_role 表
意义:主要用于定义系统中的角色。角色是权限的集合载体,通过设置不同的角色,如管理员、普通用户、审核员等,可将一组相关权限赋予该角色。便于对用户进行批量的权限管理,提高权限分配和管理的效率,是权限控制体系中的关键组成部分。
#system_role 表
id:角色唯一标识,用于在系统中区分不同角色,进行权限关联等操作 。
name:角色名称,方便用户识别角色用途,如 “管理员”“普通用户” 等 。
code:角色编码,可用于程序内部逻辑判断和权限控制代码中,作为角色的唯一代码标识 。
sort:角色排序字段,用于前端展示角色列表时的排序 。
data_scope:数据权限范围(如全部数据、本部门数据、自定义数据范围 ),控制角色能访问的数据范围 。
data_scope_dept_ids:当 data_scope 为自定义数据范围时,存储可访问部门 ID 集合 。
status:角色状态(启用、禁用 ),决定角色是否生效,影响关联用户的权限 。
type:角色类型(如系统角色、自定义角色 ),用于区分角色性质 。
remark:关于角色的备注信息,如角色职责说明等 。
creator:创建该角色记录的操作人 ,用于操作追溯。
create_time:角色记录创建时间,用于审计和分析 。
updater:最后更新该角色记录的操作人 ,记录操作历史。
update_time:角色记录最后更新时间,用于数据版本管理 。
deleted:逻辑删除标识(0 表示未删除,1 表示已删除 ),方便数据管理和恢复 。
tenant_id:多租户标识,区分不同租户下的角色数据 。
system_user_role 表
意义:作为用户和角色的关联表,用于建立用户与角色之间的多对多关系。一个用户可以拥有多个角色(如既具有普通用户角色,又具有审核角色 ),一个角色也可以被多个用户拥有。该表让系统能够明确每个用户所具备的角色,进而确定其相应权限。
#system_user_role 表
id:表记录唯一标识 。
user_id:关联用户表的用户 ID,用于建立用户和角色的关联关系 。
role_id:关联角色表的角色 ID,用于建立用户和角色的关联关系 。
creator:创建该关联记录的操作人 ,记录操作来源。
create_time:关联记录创建时间,用于审计 。
updater:最后更新该关联记录的操作人 ,记录操作历史。
update_time:关联记录最后更新时间,用于数据变更追溯 。
deleted:逻辑删除标识(0 表示未删除,1 表示已删除 ),方便管理关联关系 。
tenant_id:多租户标识,区分不同租户下的用户 - 角色关联数据 。
system_menu 表
意义:用于管理系统中的菜单信息。它定义了系统的功能结构和导航体系,前端通过读取该表数据展示菜单,用户通过菜单访问系统功能。同时,结合权限标识等字段,可实现对菜单访问权限的控制,确保不同权限的用户看到和能操作的菜单功能不同。
# system_menu 表
id:菜单唯一标识,用于区分不同菜单记录,进行菜单管理和权限关联 。
name:菜单名称,用于前端展示和用户识别 。
permission:菜单对应的权限标识,用于在代码中判断用户是否拥有访问该菜单功能的权限 。
menu_type:标识菜单权限操作权限(如目录、菜单、按钮 ),用于更细粒度的权限控制 。
sort:菜单排序字段,用于前端菜单展示的顺序排列 。
parent_id:父菜单 ID,用于构建菜单树结构,确定菜单层级关系 。
path:菜单访问路径,用于前端路由跳转和后端接口访问 。
icon:菜单图标,用于前端展示菜单样式 。
component:菜单对应的前端组件路径,用于前端渲染菜单对应的页面或功能模块 。
status:菜单状态(启用、禁用 ),控制菜单是否在前端展示和可访问 。
creator:创建该菜单记录的操作人 ,记录操作来源。
create_time:菜单记录创建时间,用于审计 。
updater:最后更新该菜单记录的操作人 ,记录操作历史。
update_time:菜单记录最后更新时间,用于数据变更追溯 。
deleted:逻辑删除标识(0 表示未删除,1 表示已删除 ),方便菜单数据管理 。
tenant_id:多租户标识,区分不同租户下的菜单数据 。
system_role_menu 表
意义:是角色和菜单的关联表,用于建立角色与菜单之间的多对多关系。它决定了不同角色能够访问哪些菜单功能,即通过此表将角色与具体的菜单权限进行绑定,从而实现基于角色的菜单访问控制,是实现系统权限管理中菜单权限分配的关键表 。
# system_role_menu 表
id:表记录唯一标识 。
role_id:关联角色表的角色 ID,用于建立角色和菜单的关联关系 。
menu_id:关联菜单表的菜单 ID,用于建立角色和菜单的关联关系 。
creator:创建该关联记录的操作人 ,记录操作来源。
create_time:关联记录创建时间,用于审计 。
updater:最后更新该关联记录的操作人 ,记录操作历史。
update_time:关联记录最后更新时间,用于数据变更追溯 。
deleted:逻辑删除标识(0 表示未删除,1 表示已删除 ),方便管理角色 - 菜单关联关系 。
tenant_id:多租户标识,区分不同租户下的角色 - 菜单关联数据 。
2.基于Token的认证流程
2.1使用账号密码登录,返回对应的token
2.1.1校验验证码
@Overridepublic AuthLoginRespVO login(AuthLoginReqVO reqVO) {// 校验验证码validateCaptcha(reqVO);// 使用账号密码,进行登录AdminUserDO user = authenticate(reqVO.getUsername(), reqVO.getPassword());// 如果 socialType 非空,说明需要绑定社交用户(什么方法登录的时候传socialtype)if (reqVO.getSocialType() != null) {socialUserService.bindSocialUser(new SocialUserBindReqDTO(user.getId(), getUserType().getValue(),reqVO.getSocialType(), reqVO.getSocialCode(), reqVO.getSocialState()));}// 创建 Token 令牌,记录登录日志return createTokenAfterLoginSuccess(user.getId(), reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME);}
@VisibleForTestingvoid validateCaptcha(AuthLoginReqVO reqVO) {ResponseModel response = doValidateCaptcha(reqVO);// 校验验证码if (!response.isSuccess()) {// 创建登录失败日志(验证码不正确)createLoginLog(null, reqVO.getUsername(), LoginLogTypeEnum.LOGIN_USERNAME, LoginResultEnum.CAPTCHA_CODE_ERROR);throw exception(AUTH_LOGIN_CAPTCHA_CODE_ERROR, response.getRepMsg());}}private ResponseModel doValidateCaptcha(CaptchaVerificationReqVO reqVO) {// 如果验证码关闭,则不进行校验if (!captchaEnable) {return ResponseModel.success();}ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);CaptchaVO captchaVO = new CaptchaVO();captchaVO.setCaptchaVerification(reqVO.getCaptchaVerification());return captchaService.verification(captchaVO);}
ValidationUtils.validate() 方法参数
ValidationUtils.validate(validator, reqVO, CaptchaVerificationReqVO.CodeEnableGroup.class);
validator
参数
作用:执行实际校验工作的
Validator
实例来源:通常通过
Validation.buildDefaultValidatorFactory().getValidator()
获取职责:
解析对象上的校验注解
执行对应的校验逻辑
收集校验结果
reqVO
参数
作用:需要被校验的对象实例
类型:应该是
CaptchaVerificationReqVO
或其它包含校验注解的DTO对象特点:
该对象类上应该定义了各种校验注解(如
@NotBlank
,@Size
等)可能包含分组标记的校验注解
CaptchaVerificationReqVO.CodeEnableGroup.class
参数
作用:校验分组标记
意义:
只校验属于
CodeEnableGroup
分组的约束忽略不属于该分组的其他约束
2.1.2使用账号密码,进行登录
@Overridepublic AdminUserDO authenticate(String username, String password) {final LoginLogTypeEnum logTypeEnum = LoginLogTypeEnum.LOGIN_USERNAME;// 校验账号是否存在AdminUserDO user = userService.getUserByUsername(username);if (user == null) {createLoginLog(null, username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);throw exception(AUTH_LOGIN_BAD_CREDENTIALS);}if (!userService.isPasswordMatch(password, user.getPassword())) {createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.BAD_CREDENTIALS);throw exception(AUTH_LOGIN_BAD_CREDENTIALS);}// 校验是否禁用if (CommonStatusEnum.isDisable(user.getStatus())) {createLoginLog(user.getId(), username, logTypeEnum, LoginResultEnum.USER_DISABLED);throw exception(AUTH_LOGIN_USER_DISABLED);}return user;}
根据是否使用社交帐号登录判断是否将系统用户和社交帐户进行绑定解绑等操作;
@Override@Transactional(rollbackFor = Exception.class)public String bindSocialUser(SocialUserBindReqDTO reqDTO) {// 获得社交用户SocialUserDO socialUser = authSocialUser(reqDTO.getSocialType(), reqDTO.getUserType(),reqDTO.getCode(), reqDTO.getState());Assert.notNull(socialUser, "社交用户不能为空");// 社交用户可能之前绑定过别的用户,需要进行解绑(例:微信绑定过其他账号)socialUserBindMapper.deleteByUserTypeAndSocialUserId(reqDTO.getUserType(), socialUser.getId());// 用户可能之前已经绑定过该社交类型,需要进行解绑(例:本账号之前绑定过其他微信账号)socialUserBindMapper.deleteByUserTypeAndUserIdAndSocialType(reqDTO.getUserType(), reqDTO.getUserId(),socialUser.getType());// 绑定当前登录的社交用户SocialUserBindDO socialUserBind = SocialUserBindDO.builder().userId(reqDTO.getUserId()).userType(reqDTO.getUserType()).socialUserId(socialUser.getId()).socialType(socialUser.getType()).build();socialUserBindMapper.insert(socialUserBind);return socialUser.getOpenid();}
案例 场景描述 关键操作 结果
1 首次绑定 直接创建新绑定 成功绑定
2 更换同类型社交账号 先删除旧绑定,再创建新绑定 绑定关系更新
3 社交账号已被其他用户绑定 先解除他人的绑定,再创建新绑定 原用户需重新绑定
4 绑定不同类型社交账号 各自独立绑定 多社交账号共存
5 绑定过程出错 事务回滚 无变更
2.1.3创建 Token 令牌,记录登录日志
private AuthLoginRespVO createTokenAfterLoginSuccess(Long userId, String username, LoginLogTypeEnum logType) {// 插入登陆日志createLoginLog(userId, username, logType, LoginResultEnum.SUCCESS);// 创建访问令牌OAuth2AccessTokenDO accessTokenDO = oauth2TokenService.createAccessToken(userId, getUserType().getValue(),OAuth2ClientConstants.CLIENT_ID_DEFAULT, null);// 构建返回结果return AuthConvert.INSTANCE.convert(accessTokenDO);}
插入日志
private void createLoginLog(Long userId, String username,LoginLogTypeEnum logTypeEnum, LoginResultEnum loginResult) {// 插入登录日志LoginLogCreateReqDTO reqDTO = new LoginLogCreateReqDTO();reqDTO.setLogType(logTypeEnum.getType());reqDTO.setTraceId(TracerUtils.getTraceId());reqDTO.setUserId(userId);reqDTO.setUserType(getUserType().getValue());reqDTO.setUsername(username);reqDTO.setUserAgent(ServletUtils.getUserAgent());reqDTO.setUserIp(ServletUtils.getClientIP());reqDTO.setResult(loginResult.getResult());loginLogService.createLoginLog(reqDTO);// 更新最后登录时间if (userId != null && Objects.equals(LoginResultEnum.SUCCESS.getResult(), loginResult.getResult())) {userService.updateUserLogin(userId, ServletUtils.getClientIP());}}
创建令牌
@Override@Transactional(rollbackFor = Exception.class)public OAuth2AccessTokenDO createAccessToken(Long userId, Integer userType, String clientId, List<String> scopes) {OAuth2ClientDO clientDO = oauth2ClientService.validOAuthClientFromCache(clientId);// 创建刷新令牌OAuth2RefreshTokenDO refreshTokenDO = createOAuth2RefreshToken(userId, userType, clientDO, scopes);// 创建访问令牌return createOAuth2AccessToken(refreshTokenDO, clientDO);}
private OAuth2RefreshTokenDO createOAuth2RefreshToken(Long userId, Integer userType, OAuth2ClientDO clientDO, List<String> scopes) {OAuth2RefreshTokenDO refreshToken = new OAuth2RefreshTokenDO().setRefreshToken(generateRefreshToken()).setUserId(userId).setUserType(userType).setClientId(clientDO.getClientId()).setScopes(scopes).setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getRefreshTokenValiditySeconds()));oauth2RefreshTokenMapper.insert(refreshToken);return refreshToken;}
private OAuth2AccessTokenDO createOAuth2AccessToken(OAuth2RefreshTokenDO refreshTokenDO, OAuth2ClientDO clientDO) {OAuth2AccessTokenDO accessTokenDO = new OAuth2AccessTokenDO().setAccessToken(generateAccessToken()).setUserId(refreshTokenDO.getUserId()).setUserType(refreshTokenDO.getUserType()).setUserInfo(buildUserInfo(refreshTokenDO.getUserId(), refreshTokenDO.getUserType())).setClientId(clientDO.getClientId()).setScopes(refreshTokenDO.getScopes()).setRefreshToken(refreshTokenDO.getRefreshToken()).setExpiresTime(LocalDateTime.now().plusSeconds(clientDO.getAccessTokenValiditySeconds()));accessTokenDO.setTenantId(TenantContextHolder.getTenantId()); // 手动设置租户编号,避免缓存到 Redis 的时候,无对应的租户编号oauth2AccessTokenMapper.insert(accessTokenDO);// 记录到 Redis 中oauth2AccessTokenRedisDAO.set(accessTokenDO);return accessTokenDO;}
2.2使用得到的token进行前端请求及鉴权
2.2.1得到token并构建对应的用户
每次请求 → 提取 token → 验证 token 合法性 → 获取用户信息 → 注入 Spring Security 上下文 → 请求继续执行
@SuppressWarnings("NullableProblems")protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)throws ServletException, IOException {String token = SecurityFrameworkUtils.obtainAuthorization(request,securityProperties.getTokenHeader(), securityProperties.getTokenParameter());if (StrUtil.isNotEmpty(token)) {Integer userType = WebFrameworkUtils.getLoginUserType(request);try {// 1.1 基于 token 构建登录用户LoginUser loginUser = buildLoginUserByToken(token, userType);// 2. 设置当前用户if (loginUser != null) {SecurityFrameworkUtils.setLoginUser(loginUser, request);}} catch (Throwable ex) {CommonResult<?> result = globalExceptionHandler.allExceptionHandler(request, ex);ServletUtils.writeJSON(response, result);return;}}// 继续过滤链chain.doFilter(request, response);}
根据token创建登录用户
private LoginUser buildLoginUserByToken(String token, Integer userType) {try {OAuth2AccessTokenCheckRespDTO accessToken = oauth2TokenApi.checkAccessToken(token);if (accessToken == null) {return null;}// 用户类型不匹配,无权限// 注意:只有 /admin-api/* 和 /app-api/* 有 userType,才需要比对用户类型// 类似 WebSocket 的 /ws/* 连接地址,是不需要比对用户类型的if (userType != null&& ObjectUtil.notEqual(accessToken.getUserType(), userType)) {throw new AccessDeniedException("错误的用户类型");}// 构建登录用户return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType()).setInfo(accessToken.getUserInfo()) // 额外的用户信息.setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes()).setExpiresTime(accessToken.getExpiresTime());} catch (ServiceException serviceException) {// 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可return null;}}
2.2.2接口请求鉴权过程
@PostMapping("/create")@Operation(summary = "新增用户")@PreAuthorize("@ss.hasPermission('system:user:create')")public CommonResult<Long> createUser(@Valid @RequestBody UserSaveReqVO reqVO) {Long id = userService.createUser(reqVO);return success(id);}
@Overridepublic boolean hasAnyPermissions(Long userId, String... permissions) {// 如果为空,说明已经有权限(全权限)if (ArrayUtil.isEmpty(permissions)) {return true;}// 获得当前登录的角色。如果为空,说明没有权限List<RoleDO> roles = getEnableUserRoleListByUserIdFromCache(userId);if (CollUtil.isEmpty(roles)) {return false;}// 情况一:遍历判断每个权限,如果有一满足,说明有权限for (String permission : permissions) {if (hasAnyPermission(roles, permission)) {return true;}}// 情况二:如果是超管,也说明有权限return roleService.hasAnySuperAdmin(convertSet(roles, RoleDO::getId));}
/*** 获得用户拥有的角色,并且这些角色是开启状态的** @param userId 用户编号* @return 用户拥有的角色*/@VisibleForTestingList<RoleDO> getEnableUserRoleListByUserIdFromCache(Long userId) {// 获得用户拥有的角色编号Set<Long> roleIds = getSelf().getUserRoleIdListByUserIdFromCache(userId);// 获得角色数组,并移除被禁用的List<RoleDO> roles = roleService.getRoleListFromCache(roleIds);roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus()));return roles;}
当 @PreAuthorize
注解里的 Spring EL 表达式返回 false
时,表示没有权限。
而 @PreAuthorize("@ss.hasPermission('system:user:list')")
表示调用 Bean 名字为 ss
的 #hasPermission(...)
方法,方法参数为 "system:user:list"
字符串。
3.自定义权限配置
默认配置下,所有接口都需要登录后才能访问,不限于管理后台的 /admin-api/**
所有 API 接口、用户 App 的 /app-api/**
所有 API 接口。
如下想要自定义权限配置,设置定义 API 接口可以匿名(不登录)进行访问,可以通过下面三种方式:
3.1自定义 AuthorizeRequestsCustomizer 实现
WebSecurityConfigurerAdapter 是一个 Spring Security 的配置类,用于定义权限认证规则和安全配置。它通过自定义 SecurityFilterChain 来设置 URL 访问权限、Token 认证、跨域支持等功能。
1️⃣WebSecurityConfigurerAdapter
🔧 功能定位:Spring Security 的主配置类
它定义了:
-
全局安全策略(如禁用 Session、启用 Token 认证等)
-
配置认证过滤器链
-
定义通用的权限放行规则(如静态资源、@PermitAll 注解)
-
统一收集所有模块的自定义权限规则(这是与第二个类关联的关键点)
🌐 属于“框架层配置”。
/*** 自定义的 Spring Security 配置适配器实现*/
@AutoConfiguration
@AutoConfigureOrder(-1) // 目的:先于 Spring Security 自动配置,避免一键改包后,org.* 基础包无法生效
@EnableMethodSecurity(securedEnabled = true)
public class WebSecurityConfigurerAdapter {@Resourceprivate WebProperties webProperties;@Resourceprivate SecurityProperties securityProperties;/*** 认证失败处理类 Bean*/@Resourceprivate AuthenticationEntryPoint authenticationEntryPoint;/*** 权限不够处理器 Bean*/@Resourceprivate AccessDeniedHandler accessDeniedHandler;/*** Token 认证过滤器 Bean*/@Resourceprivate TokenAuthenticationFilter authenticationTokenFilter;/*** 自定义的权限映射 Bean 们** @see #filterChain(HttpSecurity)*/@Resourceprivate List<AuthorizeRequestsCustomizer> authorizeRequestsCustomizers;@Resourceprivate ApplicationContext applicationContext;/*** 由于 Spring Security 创建 AuthenticationManager 对象时,没声明 @Bean 注解,导致无法被注入* 通过覆写父类的该方法,添加 @Bean 注解,解决该问题*/@Beanpublic AuthenticationManager authenticationManagerBean(AuthenticationConfiguration authenticationConfiguration) throws Exception {return authenticationConfiguration.getAuthenticationManager();}/*** 配置 URL 的安全配置** anyRequest | 匹配所有请求路径* access | SpringEl表达式结果为true时可以访问* anonymous | 匿名可以访问* denyAll | 用户不能访问* fullyAuthenticated | 用户完全认证可以访问(非remember-me下自动登录)* hasAnyAuthority | 如果有参数,参数表示权限,则其中任何一个权限可以访问* hasAnyRole | 如果有参数,参数表示角色,则其中任何一个角色可以访问* hasAuthority | 如果有参数,参数表示权限,则其权限可以访问* hasIpAddress | 如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问* hasRole | 如果有参数,参数表示角色,则其角色可以访问* permitAll | 用户可以任意访问* rememberMe | 允许通过remember-me登录的用户访问* authenticated | 用户登录后可访问*/@Beanprotected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {// 登出httpSecurity// 开启跨域.cors(Customizer.withDefaults())// CSRF 禁用,因为不使用 Session.csrf(AbstractHttpConfigurer::disable)// 基于 token 机制,所以不需要 Session.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)).headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))// 一堆自定义的 Spring Security 处理器.exceptionHandling(c -> c.authenticationEntryPoint(authenticationEntryPoint).accessDeniedHandler(accessDeniedHandler));// 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高// 获得 @PermitAll 带来的 URL 列表,免登录Multimap<HttpMethod, String> permitAllUrls = getPermitAllUrlsFromAnnotations();// 设置每个请求的权限httpSecurity// ①:全局共享规则.authorizeHttpRequests(c -> c// 1.1 静态资源,可匿名访问.requestMatchers(HttpMethod.GET, "/*.html", "/*.css", "/*.js").permitAll()// 1.2 设置 @PermitAll 无需认证.requestMatchers(HttpMethod.GET, permitAllUrls.get(HttpMethod.GET).toArray(new String[0])).permitAll().requestMatchers(HttpMethod.POST, permitAllUrls.get(HttpMethod.POST).toArray(new String[0])).permitAll().requestMatchers(HttpMethod.PUT, permitAllUrls.get(HttpMethod.PUT).toArray(new String[0])).permitAll().requestMatchers(HttpMethod.DELETE, permitAllUrls.get(HttpMethod.DELETE).toArray(new String[0])).permitAll().requestMatchers(HttpMethod.HEAD, permitAllUrls.get(HttpMethod.HEAD).toArray(new String[0])).permitAll().requestMatchers(HttpMethod.PATCH, permitAllUrls.get(HttpMethod.PATCH).toArray(new String[0])).permitAll()// 1.3 基于 moyun.security.permit-all-urls 无需认证.requestMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll())// ②:每个项目的自定义规则.authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c)))// ③:兜底规则,必须认证.authorizeHttpRequests(c -> c.dispatcherTypeMatchers(DispatcherType.ASYNC).permitAll() // WebFlux 异步请求,无需认证,目的:SSE 场景.anyRequest().authenticated());// 添加 Token FilterhttpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);return httpSecurity.build();}private String buildAppApi(String url) {return webProperties.getAppApi().getPrefix() + url;}private Multimap<HttpMethod, String> getPermitAllUrlsFromAnnotations() {Multimap<HttpMethod, String> result = HashMultimap.create();// 获得接口对应的 HandlerMethod 集合RequestMappingHandlerMapping requestMappingHandlerMapping = (RequestMappingHandlerMapping)applicationContext.getBean("requestMappingHandlerMapping");Map<RequestMappingInfo, HandlerMethod> handlerMethodMap = requestMappingHandlerMapping.getHandlerMethods();// 获得有 @PermitAll 注解的接口for (Map.Entry<RequestMappingInfo, HandlerMethod> entry : handlerMethodMap.entrySet()) {HandlerMethod handlerMethod = entry.getValue();if (!handlerMethod.hasMethodAnnotation(PermitAll.class)) {continue;}Set<String> urls = new HashSet<>();if (entry.getKey().getPatternsCondition() != null) {urls.addAll(entry.getKey().getPatternsCondition().getPatterns());}if (entry.getKey().getPathPatternsCondition() != null) {urls.addAll(convertList(entry.getKey().getPathPatternsCondition().getPatterns(), PathPattern::getPatternString));}if (urls.isEmpty()) {continue;}// 特殊:使用 @RequestMapping 注解,并且未写 method 属性,此时认为都需要免登录Set<RequestMethod> methods = entry.getKey().getMethodsCondition().getMethods();if (CollUtil.isEmpty(methods)) {result.putAll(HttpMethod.GET, urls);result.putAll(HttpMethod.POST, urls);result.putAll(HttpMethod.PUT, urls);result.putAll(HttpMethod.DELETE, urls);result.putAll(HttpMethod.HEAD, urls);result.putAll(HttpMethod.PATCH, urls);continue;}// 根据请求方法,添加到 result 结果entry.getKey().getMethodsCondition().getMethods().forEach(requestMethod -> {switch (requestMethod) {case GET:result.putAll(HttpMethod.GET, urls);break;case POST:result.putAll(HttpMethod.POST, urls);break;case PUT:result.putAll(HttpMethod.PUT, urls);break;case DELETE:result.putAll(HttpMethod.DELETE, urls);break;case HEAD:result.putAll(HttpMethod.HEAD, urls);break;case PATCH:result.putAll(HttpMethod.PATCH, urls);break;}});}return result;}
}
filterChain
- 功能:
配置 Spring Security 的核心过滤链,定义了 URL 访问规则、跨域支持、CSRF 配置、Token 认证等。- 参数:
- HttpSecurity httpSecurity:Spring Security 提供的配置对象,用于设置安全策略。
- 返回值:
- SecurityFilterChain:Spring Security 的过滤链,定义了所有安全规则。
- 详细步骤:
- 跨域支持:.cors(Customizer.withDefaults()) 开启 CORS 支持。
- CSRF 禁用:.csrf(AbstractHttpConfigurer::disable) 因为使用 Token 认证,不需要 CSRF 保护。
- 无状态会话:.sessionManagement(c -> c.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) 使用 Token 认证,不需要 Session。
- Frame 选项:.headers(c -> c.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable)) 禁用 Frame 保护,允许 iframe 嵌入。
- 异常处理:设置认证失败和权限不足的处理器。
- URL 权限规则:
- 静态资源(/*.html, /*.css, /*.js)允许匿名访问。
- 通过 getPermitAllUrlsFromAnnotations() 获取 @PermitAll 注解的 URL,允许匿名访问。
- 使用 securityProperties.getPermitAllUrls() 获取配置文件的 permit-all-urls,允许匿名访问。
- 使用 authorizeRequestsCustomizers 应用自定义规则,遍历 authorizeRequestsCustomizers 列表,依次调用每个 AuthorizeRequestsCustomizer 实例的 customize 方法。
- 异步请求(DispatcherType.ASYNC)允许匿名访问(用于 SSE 场景)。
- 其他所有请求(anyRequest())需要认证。
- 添加 Token 过滤器:在 UsernamePasswordAuthenticationFilter 之前添加 authenticationTokenFilter。
注:
每个 Maven Module 可以定义自己的 AuthorizeRequestsCustomizer 实现类,作为 Spring Bean 注入到容器中。例如,yudao-module-infra 模块可以定义一个 InfraAuthorizeRequestsCustomizer,yudao-module-system 模块可以定义 SystemAuthorizeRequestsCustomizer。
在 WebSecurityConfigurerAdapter 的 filterChain 方法中,通过 @Resource private List authorizeRequestsCustomizers收集所有 AuthorizeRequestsCustomizer Bean。
核心代码(在 filterChain 方法中):
.authorizeHttpRequests(c -> authorizeRequestsCustomizers.forEach(customizer -> customizer.customize(c)))
功能:遍历 authorizeRequestsCustomizers 列表,依次调用每个 AuthorizeRequestsCustomizer 实例的 customize 方法。
参数:
c 是 AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry 类型,提供了 requestMatchers 等方法,用于定义 URL 匹配和权限规则。
实现:
每个 AuthorizeRequestsCustomizer 实现类通过 customize 方法添加模块特定的规则。例如,允许某些 URL 匿名访问或需要特定权限。
动态匹配:
customize 方法是抽象的,子类必须实现它。子类可以通过 requestMatchers 方法指定 URL 模式(如 /admin-api/**)和权限策略(如 permitAll()、authenticated())。
2️⃣ SecurityConfiguration
🔧 功能定位:某个模块(infra)的权限自定义扩展配置
它只做一件事:
-
提供一个名为
infraAuthorizeRequestsCustomizer
的AuthorizeRequestsCustomizer
Bean,用于告诉主配置类:有哪些路径可以免认证访问(比如 Swagger、Actuator、Druid、文件接口等)。
🧩 属于“模块层扩展”。
/*** Infra 模块的 Security 配置*/
@Configuration(proxyBeanMethods = false, value = "infraSecurityConfiguration")
public class SecurityConfiguration {@Value("${spring.boot.admin.context-path:''}")private String adminSeverContextPath;@Bean("infraAuthorizeRequestsCustomizer")public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {return new AuthorizeRequestsCustomizer() {@Overridepublic void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {// Swagger 接口文档registry.requestMatchers("/v3/api-docs/**").permitAll().requestMatchers("/webjars/**").permitAll().requestMatchers("/swagger-ui.html").permitAll().requestMatchers("/swagger-ui/**").permitAll();// Spring Boot Actuator 的安全配置registry.requestMatchers("/actuator").permitAll().requestMatchers("/actuator/**").permitAll();// Druid 监控registry.requestMatchers("/druid/**").permitAll();// Spring Boot Admin Server 的安全配置registry.requestMatchers(adminSeverContextPath).permitAll().requestMatchers(adminSeverContextPath + "/**").permitAll();// 文件读取registry.requestMatchers(buildAdminApi("/infra/file/*/get/**")).permitAll();}};}}
全过程
- 应用过程:
- Spring 容器启动时,扫描到 InfraAuthorizeRequestsCustomizer 和 SystemAuthorizeRequestsCustomizer,注入到 authorizeRequestsCustomizers 列表。
- 在 filterChain 方法中,依次调用:
- InfraAuthorizeRequestsCustomizer.customize()(优先级 1),设置 /admin-api/infra/file/*/get/** 为匿名访问。
- SystemAuthorizeRequestsCustomizer.customize()(优先级 2),设置 /admin-api/system/user/** 需 ADMIN 角色。
- 最终生成的 SecurityFilterChain 包含所有模块的规则,按优先级顺序应用。
- URL 匹配:
使用 requestMatchers 方法支持 Ant 风格路径(如 /admin-api/**)或正则表达式,Spring Security 会根据请求的 URL 和 HTTP 方法与规则进行匹配。 - 权限检查:
匹配成功后,Spring Security 会根据 permitAll()、authenticated() 或 hasRole() 等策略决定是否允许访问。 - 冲突处理:
如果多个规则匹配同一 URL,后定义的规则会覆盖前面的规则(基于优先级和顺序)。
总结:
+-----------------------------------------------------------+
| WebSecurityConfigurerAdapter |
| |
| - 构建 HttpSecurity 策略 |
| - 允许静态资源、@PermitAll、全局配置路径免认证 |
| - 遍历调用: |
| authorizeRequestsCustomizers.forEach(customizer) |
| |
+-----------------------------------------------------------+↑|Spring 自动装配的 AuthorizeRequestsCustomizer Bean|
+-----------------------------------------------------------+
| SecurityConfiguration (infra模块) |
| |
| @Bean -> infraAuthorizeRequestsCustomizer |
| - 配置 swagger、actuator、druid 等路径 permitAll |
+-----------------------------------------------------------+
这种设计体现了 分层解耦 + 开放扩展性 的原则:
-
🧩 分模块配置权限:每个模块只需要注册自己的
AuthorizeRequestsCustomizer
即可,而不用动核心安全逻辑。 -
🔧 核心逻辑集中管理:
WebSecurityConfigurerAdapter
统一处理认证、异常处理、Token 过滤器等关键配置,避免散乱。 -
🧹 避免权限配置集中臃肿:不需要在主配置类里手动维护一堆
registry.requestMatchers(...)
。
对比点 WebSecurityConfigurerAdapter
SecurityConfiguration
作用层级 框架级(全局) 模块级(局部) 主要功能 配置 HttpSecurity、认证逻辑、处理器、Token 过滤器等 添加模块内自定义免认证 URL 使用方式 被 Spring 自动加载 注册 AuthorizeRequestsCustomizer
Bean与对方关系 调用并执行所有 AuthorizeRequestsCustomizer
被调用者,提供自定义配置 是否推荐改动 一般不要动,改动需了解安全机制 可自由扩展,适合业务模块使用
3.2@PermitAll
注解
在 API 接口上添加 @PermitAll 注解,示例如下:
// FileController.java
@GetMapping("/{configId}/get/{path}")
@PermitAll
public void getFileContent(HttpServletResponse response,@PathVariable("configId") Long configId,@PathVariable("path") String path) throws Exception {// ...
}
3.3yudao.security.permit-all-urls
配置项
在 application.yaml
配置文件,通过 yudao.security.permit-all-urls
配置项设置,示例如下:
yudao:security:permit-all-urls:- /admin-ui/** # /resources/admin-ui 目录下的静态资源- /admin-api/xxx/yyy