# scaffold 项目之功能权限

# 功能权限

# 基于 RBAC 权限模型(Role-Based Access Control)的角色访问控制

包含一下表:

用户角色用户角色关联表菜单表(权限表)角色权限关联表

一个用户有多个角色,一个角色有多个权限

# Token 认证机制

认证框架是 spring security+Token 的方式,分为以下步骤:

  1. 前端带用户名密码调用登陆接口,成功校验返回 Token
  2. 调用其他接口带上校验成功的 Token,返回对应的数据

响应实例:

{
    "code":0,
    "message":"",
    "data":{
		"token":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
    }
}

具体登陆代码:

/**
 * <p> Project: scaffold - AuthController </p>
 *
 * 管理后台 - 认证
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Tag(name = "管理后台 - 认证")
@RestController
@RequestMapping("/system/auth")
@Validated
@Slf4j
public class AuthController {
    @Resource
    private AdminAuthService authService;
    @Resource
    private AdminUserService userService;
    @Resource
    private RoleService roleService;
    @Resource
    private MenuService menuService;
    @Resource
    private PermissionService permissionService;
    @Resource
    private SocialClientService socialClientService;
    @Resource
    private SecurityProperties securityProperties;
    @PostMapping("/login")
    @PermitAll
    @Operation(summary = "使用账号密码登录")
    @OperateLog(enable = false)
    public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
        return success(authService.login(reqVO));
    }
    @PostMapping("/logout")
    @PermitAll
    @Operation(summary = "登出系统")
    @OperateLog(enable = false)
    public CommonResult<Boolean> logout(HttpServletRequest request) {
        String token = SecurityFrameworkUtils.obtainAuthorization(request,
                securityProperties.getTokenHeader(), securityProperties.getTokenParameter());
        if (StrUtil.isNotBlank(token)) {
            authService.logout(token, LoginLogTypeEnum.LOGOUT_SELF.getType());
        }
        return success(true);
    }
    @PostMapping("/refresh-token")
    @PermitAll
    @Operation(summary = "刷新令牌")
    @Parameter(name = "refreshToken", description = "刷新令牌", required = true)
    @OperateLog(enable = false)
    public CommonResult<AuthLoginRespVO> refreshToken(@RequestParam("refreshToken") String refreshToken) {
        return success(authService.refreshToken(refreshToken));
    }
    @GetMapping("/get-permission-info")
    @Operation(summary = "获取登录用户的权限信息")
    public CommonResult<AuthPermissionInfoRespVO> getPermissionInfo() {
        // 1.1 获得用户信息
        AdminUserDO user = userService.getUser(getLoginUserId());
        if (user == null) {
            return null;
        }
        // 1.2 获得角色列表
        Set<Long> roleIds = permissionService.getUserRoleIdListByUserId(getLoginUserId());
        if (CollUtil.isEmpty(roleIds)) {
            return success(AuthConvert.INSTANCE.convert(user, Collections.emptyList(), Collections.emptyList()));
        }
        List<RoleDO> roles = roleService.getRoleList(roleIds);
        // 移除禁用的角色
        roles.removeIf(role -> !CommonStatusEnum.ENABLE.getStatus().equals(role.getStatus()));
        // 1.3 获得菜单列表
        Set<Long> menuIds = permissionService.getRoleMenuListByRoleId(convertSet(roles, RoleDO::getId));
        List<MenuDO> menuList = menuService.getMenuList(menuIds);
        // 移除禁用的菜单
        menuList.removeIf(menu -> !CommonStatusEnum.ENABLE.getStatus().equals(menu.getStatus()));
        // 2. 拼接结果返回
        return success(AuthConvert.INSTANCE.convert(user, roles, menuList));
    }
    // ========== 短信登录相关 ==========
    @PostMapping("/sms-login")
    @PermitAll
    @Operation(summary = "使用短信验证码登录")
    @OperateLog(enable = false)
    public CommonResult<AuthLoginRespVO> smsLogin(@RequestBody @Valid AuthSmsLoginReqVO reqVO) {
        return success(authService.smsLogin(reqVO));
    }
    @PostMapping("/send-sms-code")
    @PermitAll
    @Operation(summary = "发送手机验证码")
    @OperateLog(enable = false)
    public CommonResult<Boolean> sendLoginSmsCode(@RequestBody @Valid AuthSmsSendReqVO reqVO) {
        authService.sendSmsCode(reqVO);
        return success(true);
    }
    // ========== 社交登录相关 ==========
    @GetMapping("/social-auth-redirect")
    @PermitAll
    @Operation(summary = "社交授权的跳转")
    @Parameters({
            @Parameter(name = "type", description = "社交类型", required = true),
            @Parameter(name = "redirectUri", description = "回调路径")
    })
    public CommonResult<String> socialLogin(@RequestParam("type") Integer type,
                                            @RequestParam("redirectUri") String redirectUri) {
        return success(socialClientService.getAuthorizeUrl(
                type, UserTypeEnum.ADMIN.getValue(), redirectUri));
    }
    @PostMapping("/social-login")
    @PermitAll
    @Operation(summary = "社交快捷登录,使用 code 授权码", description = "适合未登录的用户,但是社交账号已绑定用户")
    @OperateLog(enable = false)
    public CommonResult<AuthLoginRespVO> socialQuickLogin(@RequestBody @Valid AuthSocialLoginReqVO reqVO) {
        return success(authService.socialLogin(reqVO));
    }
}

如果开启了滑块验证码则:

  1. 先获取验证码,用户登陆会滑动位置
  2. 将滑动的位置发送给后端,进行校验
  3. 校验成功后登陆
  4. 登陆需要判读登陆方式,如果是第三方登陆需要对应的绑定

为什么不使用 JWT Token ?

因为它是无状态的、无法实现 Token 的作废

本系统 token 存储在表 system_oauth2_access_token token 字段,具体实现可查看 TokenAuthenticationFilter 过滤器

实现了 OncePerRequestFilter 过滤器,每次 web 请求都会先经过这个过滤器,代码如下:

/**
 * <p> Project: scaffold - TokenAuthenticationFilter </p>
 *
 * Token 过滤器,验证 token 的有效性
 * <p>
 * 验证通过后,获得 {@link LoginUser} 信息,并加入到 Spring Security 上下文
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Component
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    @Resource
    private OAuth2Client oauth2Client;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 1. 获得访问令牌
        String token = SecurityUtils.obtainAuthorization(request, "Authorization");
        if (StringUtils.hasText(token)) {
            // 2. 基于 token 构建登录用户
            LoginUser loginUser = buildLoginUserByToken(token);
            // 3. 设置当前用户
            if (loginUser != null) {
                SecurityUtils.setLoginUser(loginUser, request);
            }
        }
        // 继续过滤链
        filterChain.doFilter(request, response);
    }
    private LoginUser buildLoginUserByToken(String token) {
        try {
            CommonResult<OAuth2CheckTokenRespDTO> accessTokenResult = oauth2Client.checkToken(token);
            OAuth2CheckTokenRespDTO accessToken = accessTokenResult.getData();
            if (accessToken == null) {
                return null;
            }
            // 构建登录用户
            return new LoginUser().setId(accessToken.getUserId()).setUserType(accessToken.getUserType())
                    .setTenantId(accessToken.getTenantId()).setScopes(accessToken.getScopes())
                    .setAccessToken(accessToken.getAccessToken());
        } catch (Exception exception) {
            // 校验 Token 不通过时,考虑到一些接口是无需登录的,所以直接返回 null 即可
            return null;
        }
    }
}

前端请求需要在请求头携带:

Authorization: Bearer aadfsdfadfasdfasdfasdfdsf

# 权限注解

# @PerAuthorize

@PerAuthorize 是 Spring Security 内置的前置校验权限的注解,添加在接口方法上,声明需要的权限,实现权限访问控制。

  1. 基于【权限标识】的权限控制

    权限标识对应 system_menu 表的 permission 字段,推荐格式为: ${系统}:${模块}:${操作} ,列如 system:admin:add 标识 system 服务的添加管理员权限

    使用实例:

    @PreAuthorize("@ss.hasPermission(system:admin:add)");
    // 满足其中一个权限要求即可
    @PreAuthorize("@ss.hasPermission(system:admin:add,system:admin:edit)");
  2. 基于【角色标识】权限控制

    权限标识对应 system_role 表的 code 字段,列如说 super_admin 超级管理员, tenant_admin 租户管理员等等

    使用实例:

    @PreAuthorize("@ss.hasRole('super_admin')");
    // 满足其中一个角色即可
    @PreAuthorize("@ss.hasRole('super_admin', 'tenant_admin')");

    实现原理:

    @PreAuthorize 注解中的 Spring EL 表达式返回 false 表示没有权限

    @PreAuthorize ("@ss.hasPermission (system:admin:add)") 表示调用 Bean 名字为 sshasPermission(...) 方法,方法的参数为: system:admin:add ,ss 具体的类看下面的内容

    // 这里 bean 的名字 ss 是 SecurityFrameworkService 类
    /**
     * Bean ("ss"): 使用 Spring Security 的缩写,方便使用
     * @param permissionApi
     * @return
     */
    @Bean("ss")
    public SecurityFrameworkService securityFrameworkService(PermissionApi permissionApi) {
        return new SecurityFrameworkServiceImpl(permissionApi);
    }

# @PreAuthenticated

@PreAuthenticated 是本系统自定义的注解,放到方法或接口上,注解功能是声明登陆的用户才能访问,比如 App 用户,访问商城,查询的商品是不需要登陆的,但是支付获取支付情况就需要

@PutMapping("/update-password")
    @Operation(summary = "修改用户密码", description = "用户修改密码时使用")
    @PreAuthenticated
    public CommonResult<Boolean> updatePassword(@RequestBody @Valid AppMemberUserUpdatePasswordReqVO reqVO) {
        userService.updateUserPassword(getLoginUserId(), reqVO);
        return success(true);
    }

具体实现逻辑:通过切面来预先进行身份验证

/**
 * <p> Project: scaffold - PreAuthenticatedAspect </p>
 *
 * 预身份验证切面
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
@Aspect
@Slf4j
public class PreAuthenticatedAspect {
    @Around("@annotation(preAuthenticated)")
    public Object around(ProceedingJoinPoint joinPoint, PreAuthenticated preAuthenticated) throws Throwable {
        if (SecurityFrameworkUtils.getLoginUser() == null) {
            throw exception(UNAUTHORIZED);
        }
        return joinPoint.proceed();
    }
}

# 自定义权限配置

在本项目中所有后台接口 /admin-api/** 所有 API 接口都需要登陆才能访问,用户 App 的接口都不需要登陆,特定的需要登陆加了 @PreAuthenticated 注解。

如果需要特定的接口可以无需登录即可访问得使用以下方式:

# 方式一

自定义 AuthorizeRequestsCustomizer 实现,提供了一种自定义 HTTP 安全配置的方法。这个类的主要作用是允许我们通过编程方式配置安全拦截器的授权规则。 (多模块下,每个模块可以直接定义单独的安全规则)

代码:

/**
 * <p> Project: scaffold - AuthorizeRequestsCustomizer </p>
 *
 * 自定义的 URL 的安全配置
 * <p>
 * 目的:每个 Maven Module 可以自定义规则!
 * @author Tz
 * @date 2024/01/09 23:45
 * @version 1.0.0
 * @since 1.0.0
 */
public abstract class AuthorizeRequestsCustomizer
        implements Customizer<ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry>, Ordered {
    @Resource
    private WebProperties webProperties;
    protected String buildAdminApi(String url) {
        return webProperties.getAdminApi().getPrefix() + url;
    }
    protected String buildAppApi(String url) {
        return webProperties.getAppApi().getPrefix() + url;
    }
    @Override
    public int getOrder() {
        return 0;
    }
}

说明:

  • Customizer<ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry>
    • 这个接口定义了如何自定义 URL 的授权规则。
    • ExpressionUrlAuthorizationConfigurer 是 Spring Security 中用于配置 URL 授权的类。
    • ExpressionInterceptUrlRegistry 是一个注册表,用于注册 URL 表达式和相应的授权配置。
  • Ordered
    • 这个接口用于定义对象的顺序。在 Spring 容器中,实现了 Ordered 接口的 bean 可以根据其 getOrder() 方法返回的值来确定加载顺序。

作用:

  • 自定义 URL 授权:通过实现 Customizer 接口, AuthorizeRequestsCustomizer 允许开发者自定义 URL 的授权规则。这包括哪些 URL 需要拦截、哪些角色或权限可以访问特定 URL 等。
  • 控制加载顺序:通过实现 Ordered 接口,可以控制这个自定义器在 Spring Security 配置中的加载顺序。这对于处理多个自定义器时非常有用,确保它们按照正确的顺序应用。

使用示例:

@Component
public class MyCustomizer extends AuthorizeRequestsCustomizer {
    @Override
    public void customize(ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry) {
        registry
            .antMatchers("/admin/**").hasRole("ADMIN")
            .antMatchers("/user/**").hasRole("USER")
            .antMatchers("/", "/home", "/register").permitAll()
            .anyRequest().authenticated();
    }
}

在这个示例中:

  • /admin/** 路径需要用户具有 ADMIN 角色。
  • /user/** 路径需要用户具有 USER 角色。
  • / , /home , /register 路径对所有人开放。
  • 其他所有请求都需要用户进行认证。

# 方式二

@PermitAll 注解方式,在 api 接口上添加该注解,那么该接口无需登陆即可访问。

@PostMapping("/login")
@PermitAll
@Operation(summary = "使用账号密码登录")
@OperateLog(enable = false)
public CommonResult<AuthLoginRespVO> login(@RequestBody @Valid AuthLoginReqVO reqVO) {
    return success(authService.login(reqVO));
}

方式三

本项目实现了可以通过在 yml 配置文件中配置,不需要登陆的 url

配置:

scaffold:
  security:
    permit-all_urls:
      - /admin/login

代码实现:

@Bean
protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception {
    // 登出
    httpSecurity
        // 开启跨域
        .cors().and()
        // CSRF 禁用,因为不使用 Session
        .csrf().disable()
        // 基于 token 机制,所以不需要 Session
        .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        .headers().frameOptions().disable().and()
        // 一堆自定义的 Spring Security 处理器
        .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint)
        .accessDeniedHandler(accessDeniedHandler);
    // 登录、登录暂时不使用 Spring Security 的拓展点,主要考虑一方面拓展多用户、多种登录方式相对复杂,一方面用户的学习成本较高
    // 获得 @PermitAll 带来的 URL 列表,免登录
    Multimap<HttpMethod, String> permitAllUrls = getPermitAllUrlsFromAnnotations();
    // 设置每个请求的权限
    httpSecurity
        // 基于 scaffold.security.permit-all-urls 无需认证
        .antMatchers(securityProperties.getPermitAllUrls().toArray(new String[0])).permitAll()
        // ③:兜底规则,必须认证
        .authorizeRequests()
        .anyRequest().authenticated()
        ;
    return httpSecurity.build();
}