0 引言

该篇文章是对Spring Security学习的总结,以及系统中认证授权的代码实现

参考链接

SpringSecurity-从入门到精通

1 Spring Security

Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。

一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。

一般Web应用的需要进行认证和授权。

认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户

授权:经过认证后判断当前用户是否有权限进行某个操作

认证和授权也是SpringSecurity作为安全框架的核心功能。

1.1 Spring Security原理初探

在这里插入图片描述

认证基本流程:

  1. 根据username, password封装一个Authentication对象authenticationToken,并加入到SecurityContextHoldercontext
  2. 调用AuthenticationManager接口的authenticate(authenticationToken)方法进行认证
  3. 在第2步调用authenticate方法时,它实际会执行实现了UserDetailsService接口的loadUserByUsername方法,认证用户的逻辑就写在该方法中
  4. loadUserByUsername方法中,从AuthenticationContextHolder获取第一步传入的username, password,与数据库的密码做对比,验证用户密码是否正确,若正确,则把用户信息封装到实现了UserDetails接口的类中

步骤1扩展

认证过滤器UsernamePasswordAuthenticationFilter:它的作用是拦截登录请求并获取账号和密码,然后把账号密码封装到认证凭据 UsernamePasswordAuthenticationToken 中,然后把凭据交 给特定配置的 AuthenticationManager去作认证。

image-20210312074036145

步骤2扩展

AuthenticationManager 的实现 ProviderManager 管理了众多的 AuthenticationProvider 。每 一个 AuthenticationProvider都只支持特定类型的 Authentication ,然后是对适配到的 Authentication 进行认证,只要有一个 AuthenticationProvider认证成功,那么就认为认证成功,所有的都没有通过才认为是认证失败。认证成功后的 Authentication 就变成授信凭据,并触发认证成功的事件。认证失败的就抛出异常触发认证失败的事件。

1.2 Spring Secutity核心内容

1.2.1 Spring Secutity中的用户信息

1.UserDetailsService

该方法很容易理解: 通过用户名来加载用户 。这个方法主要用于从系统数据中查询并加载具体的用户到 Spring Security中。

在开发中我们一般定义一个这个接口的实现类,自定义loadUserByUsername方法,实现从数据源获取用户,加载用户信息。也可以在其中实现一些校验用户逻辑。

2.UserDetails:

从上面 UserDetailsService 可以知道最终交给Spring Security的是UserDetails 。该接口是提供用户信息的核心接口。该接口实现仅仅存储用户的信息。后续会将该接口提供的用户信息封装到认证对象Authentication 中去。

UserDetails中默认提供:

  • 用户的权限集, 默认需要添加 ROLE_ 前缀
  • 用户的加密后的密码, 不加密会使用 {noop} 前缀
  • 应用内唯一的用户名
  • 账户是否过期
  • 账户是否锁定
  • 凭证是否过期
  • 用户是否可用

在我们自己的项目中,我们要定义个用户类实现该接口,在该用户类中我们可以扩展更多的用户信息,比如手机、邮箱等等

1.2.2 Spring Security的配置

自定义SecurityConfig

首先要继承WebSecurityConfigurerAdapter,其次最常用的是实现configure(AuthenticationManagerBuilder auth)configure(WebSecurity web)configure(HttpSecurity http)三个方法实现我们对Spring Security的自定义安全配置。

  • void configure(AuthenticationManagerBuilder auth) 用来配置认证管理器 AuthenticationManager
  • void configure(WebSecurity web)用来配置 WebSecurity 。而 WebSecurity是基于Servlet Filter用来配置 springSecurityFilterChain。而 springSecurityFilterChain 又被委托给了 Spring Security 核心过滤器 Bean DelegatingFilterProxy 。 相关逻辑可以在 WebSecurityConfiguration 中找到。我们一般不会过多来自定义 WebSecurity , 使用较多的使其 ignoring() 方法用来忽略 Spring Security 对静态资源的控制。
  • void configure(HttpSecurity http) 这个是我们使用最多的,用来配置 HttpSecurityHttpSecurity用于构建一个安全过滤器链 SecurityFilterChainSecurityFilterChain 最终被注入核心过滤器 。HttpSecurity有许多我们需要的配置。我们可以通过它来进行自定义安全访问策略。

1.2.3 认证过程

详见 [1.1 Spring Security原理初探](# 1.1 Spring Security原理初探)

1.2.4 过滤器和过滤链

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。

Spring Security 以一个单 Filter(FilterChainProxy)存在于整个过滤器链中,而 这个 FilterChainProxy 实际内部代理着众多的 Spring Security Filter 。

在这里插入图片描述

图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。

UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要有它负责。

ExceptionTranslationFilter: 处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。

FilterSecurityInterceptor: 负责权限校验的过滤器。

我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

在这里插入图片描述

向项目中添加过滤器:

在配置文件的configure(HttpSecurity httpSecurity)方法中:

img

4个方法分别是:addFilter–添加过滤器;addFilterAfter–把过滤器添加到某过滤器之后;addFilterAt–替代某过滤器;addFilterBefore–把过滤器添加到某过滤器之前

1.2.5 权限相关

一、基于配置表达式控制 URL 路径

在继承WebSecurityConfigurerAdapter的配置类中的configure(HttpSecurity http)中进行配置。

1
2
3
4
5
6
7
8
protected void configure(HttpSecurity http) throws Exception {
    http.authorizeRequests()
            .antMatchers("/admin/**").hasRole("admin")
            .antMatchers("/user/**").hasAnyRole("admin", "user")
            .anyRequest().authenticated()
            .and()
            ...
}

二、基于注解的接口权限控制

我们可以在任何 @Configuration实例上使用 @EnableGlobalMethodSecurity注解来启用全局方 法安全注解功能

1
2
3
4
5
6
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true,securedEnabled = true,jsr250Enabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    ...
    ...
}
  • 设置 prePostEnabled为 true ,则开启了基于表达式 的方法安全控制。

2 认证

2.1 登录校验流程

在这里插入图片描述

2.2 登录功能核心代码

在SpringBoot项目中使用SpringSecurity我们只需要引入依赖即可。

1
2
3
4
5
<!-- spring security 安全认证 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

1.根据username, password封装一个Authentication对象authenticationToken,调用AuthenticationManager接口的authenticate(authenticationToken)方法进行认证

Authentication类中:principal属性对应用户名;credentials属性对应密码

若是需要根据邮箱登录的话把email放到principal

loadUserByUsername方法的参数就是这个email

在接口中我们通过AuthenticationManagerauthenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
public String login(String username, String password, String code, String uuid) {
    // 用户验证
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    AuthenticationContextHolder.setContext(authenticationToken);
    // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
    Authentication authentication = authenticationManager.authenticate(authenticationToken);
    
    // 生成token
    return tokenService.createToken(loginUser);
}

密码加密存储

实际项目中我们不会把密码明文存储在数据库中。

一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。

我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验

部分SecurityConfig配置([完整配置](# 2.4 配置SecurityConfig))

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                //关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问
                .antMatchers("/user/login").anonymous()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated();
    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }
    
    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        // 数据库验证
        // Spring Security 提供了BCryptPasswordEncoder类,
        // 实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密密码
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

2.创建一个service,实现UserDetailsService接口,并重写loadUserByUsername方法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
@Service
public class UserDetailsServiceImpl implements UserDetailsService{
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        SysUser user = userService.selectUserByUserName(username);
       
        validate(user);

        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user)
    {
        return new LoginUser(user.getUserId(), user);
    }
}

3.实现密码验证逻辑

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public void validate(SysUser user){
    Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
    String username = usernamePasswordAuthenticationToken.getName();
    String password = usernamePasswordAuthenticationToken.getCredentials().toString();

    if (!matches(user, password))
    {
        throw new ServiceException("密码错误");
    }
}

public boolean matches(SysUser user, String rawPassword){
    return matchesPassword(rawPassword, user.getPassword());
}

/**
 * 判断密码是否相同
 *
 * @param rawPassword 真实密码
 * @param encodedPassword 加密后字符
 * @return 结果
 */
public boolean matchesPassword(String rawPassword, String encodedPassword)
{
    BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    return passwordEncoder.matches(rawPassword, encodedPassword);
}

4.验证成功后,创建令牌,存入redis中,并将token返回给前端(实现步骤1中的createToken方法)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
/**
 * 创建令牌
 *
 * @param loginUser 用户信息
 * @return 令牌
 */
public String createToken(LoginUser loginUser)
{
    String token = UUID.fastUUID().toString();
    loginUser.setToken(token);
    refreshToken(loginUser);

    Map<String, Object> claims = new HashMap<>();
    claims.put(Constants.LOGIN_USER_KEY, token);
    return createToken(claims);
}

/**
 * 刷新令牌有效期
 *
 * @param loginUser 登录信息
 */
public void refreshToken(LoginUser loginUser)
{
    loginUser.setLoginTime(System.currentTimeMillis());
    loginUser.setExpireTime(loginUser.getLoginTime() + expireTime * MILLIS_MINUTE);
    // 根据uuid将loginUser缓存
    String userKey = getTokenKey(loginUser.getToken()); // "login_tokens:" + uuid;
    redisCache.set(userKey, loginUser, expireTime, TimeUnit.MINUTES);
}

/**
 * 从数据声明生成令牌
 *
 * @param claims 数据声明
 * @return 令牌
 */
private String createToken(Map<String, Object> claims)
{
    String token = Jwts.builder()
            .setClaims(claims)
            .signWith(SignatureAlgorithm.HS512, secret).compact();
    return token;
}

2.3 校验功能核心代码

访问系统资源时,如果每次都需要去数据库验证密码是十分消耗资源的,[2.2](# 2.2 登录功能核心代码)中,最后返回了一个token,可基于该token实现保存登录状态的功能

1.前端将后端返回的token存入Cookie中

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
login(userInfo) {
  const username = userInfo.username.trim();
  const password = userInfo.password;
  return new Promise<void>((resolve, reject) => {
    login(username, password)
      .then((res: any) => {
        setToken(res.token);
        this.token = res.token;
        resolve();
      })
      .catch((error) => {
        reject(error);
      });
  });
}
1
2
3
4
5
6
import Cookies from "js-cookie";
const TokenKey = "Admin-Token";

export function setToken(token: string) {
  return Cookies.set(TokenKey, token);
}

2.请求接口时都附上token,验证用户是否登录

request.ts

1
2
3
4
5
6
7
8
9
// request拦截器
service.interceptors.request.use(
  (config: any) => {
    // 是否需要设置 token
    const isToken = (config.headers || {}).isToken === false;
    if (getToken() && !isToken) {
      // 让每个请求携带自定义token 请根据实际情况自行修改
      config.headers["Authorization"] = "Bearer " + getToken(); 
    }

3.定义Jwt认证过滤器 JwtAuthenticationTokenFilter,验证token有效性

因为redis设置了登陆令牌的过期时间,所以如果令牌过期了,redis中就不存在token解析后的redisKey,会直接返回null,这种其概况就要重新登录了。

添加依赖

1
2
3
4
5
6
<!-- Token生成与解析-->
<dependency>
    <groupId>io.jsonwebtoken</groupId>
    <artifactId>jjwt</artifactId>
    <version>${jwt.version}</version>
</dependency>

JwtAuthenticationTokenFilter

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
 * token过滤器 验证token有效性
 * 
 * @author 7bin
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        LoginUser loginUser = tokenService.getLoginUser(request);
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            // 验证令牌有效期,相差不足20分钟,自动刷新缓存
            tokenService.verifyToken(loginUser);
            // 获取权限信息封装到Authentication中
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            // 通过验证,在Spring Security上下文中写入用户登录相关所有信息
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        chain.doFilter(request, response);
    }
}

如何根据token获取登录用户信息

核心:从request解析token获取其中的userId,到redis中根据userId获取用户信息

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
/**
 * 获取用户身份信息
 *
 * @return 用户信息
 */
public LoginUser getLoginUser(HttpServletRequest request)
{
    // 获取请求携带的令牌
    String token = getToken(request);
    if (StringUtils.isNotEmpty(token))
    {
        try
        {
            Claims claims = parseToken(token);
            // 解析对应的权限以及用户信息
            String uuid = (String) claims.get(Constants.LOGIN_USER_KEY);
            String userKey = getTokenKey(uuid);
            LoginUser user = redisCache.get(userKey);
            return user;
        }
        catch (Exception e)
        {
            log.error(e.getMessage());
        }
    }
    return null;
}

/**
 * 令牌前缀
 */
public static final String TOKEN_PREFIX = "Bearer ";

/**
 * 获取请求token
 *
 * @param request
 * @return token
 */
private String getToken(HttpServletRequest request)
{
    String token = request.getHeader(header);
    if (StringUtils.isNotEmpty(token) && token.startsWith(Constants.TOKEN_PREFIX))
    {
        token = token.replace(Constants.TOKEN_PREFIX, "");
    }
    return token;
}

/**
 * 从令牌中获取数据声明
 *
 * @param token 令牌
 * @return 数据声明
 */
private Claims parseToken(String token)
{
    return Jwts.parser()
            .setSigningKey(secret)
            .parseClaimsJws(token)
            .getBody();
}

2.4 配置SecurityConfig

SecurityConfig中注入:

  • LogoutSuccessHandlerImpl
  • 自定义实现的UserDetailsService
  • JwtAuthenticationTokenFilter
  • AuthenticationEntryPoint
  • CorsFilter

以及相关权限控制configure

完整spring security配置如下

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
/**
 * spring security配置
 * 
 * @author 7bin
 */
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
    /**
     * 自定义用户认证逻辑
     */
    @Autowired
    private UserDetailsService userDetailsService;
    
    /**
     * 认证失败处理类(抛出AuthenticationException异常走这里)
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出处理类
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token认证过滤器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;
    
    /**
     * 跨域过滤器
     */
    @Autowired
    private CorsFilter corsFilter;

    /**
     * 允许匿名访问的地址
     */
    @Autowired
    private PermitAllUrlProperties permitAllUrl;

    /**
     * 解决 无法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception
    {
        return super.authenticationManagerBean();
    }

    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception
    {
        // 注解标记允许匿名访问的url
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
        permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        httpSecurity
                // CSRF禁用,因为不使用session
                // 关闭csrf功能:跨站请求伪造,默认只能通过post方式提交logout请求
                .csrf().disable()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                // anonymous() :匿名访问, 仅允许匿名用户访问, 如果登录认证后, 带有token信息再去请求, 这个anonymous()关联的资源就不能被访问,
                // permitAll() :登录能访问,不登录也能访问, 一般用于静态资源js等
                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                .antMatchers("/login", "/register", "/captchaImage","/docker/**").anonymous()
                // 静态资源,可匿名访问
                .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
                .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        // 添加Logout filter
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        // 数据库验证
        // Spring Security 提供了BCryptPasswordEncoder类,
        // 实现Spring的PasswordEncoder接口使用BCrypt强哈希方法来加密密码
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

2.5 思考

什么时候会走到认证失败的处理类呢(还不知道)

httpSecurity.exceptionHandling().authenticationEntryPoint(unauthorizedHandler)

AuthenticationException异常详解

3 授权

3.1 RBAC权限模型

RBAC权限模型(Role-Based Access Control)即:基于角色的权限控制。这是目前最常被开发者使用也是相对易用、通用权限模型。

3.1.1 数据库表结构

如下图是RBAC权限模型各表的基本属性

当判断某个用户是否拥有某个权限的时候,可根据user_role表获取到该user关联的role,之后可根据role_menu表获取到该role能够访问的menu(权限)

image-20230609205029592

3.1.2 如何获取用户具有哪些权限?

在用户登录的时候(loadUserByUsername方法中),将权限信息写入LoginUser

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
{
    SysUser user = userService.selectUserByUserName(username);
    if (StringUtils.isNull(user))
    {
        log.info("登录用户:{} 不存在.", username);
        throw new ServiceException("登录用户:" + username + " 不存在");
    }
    else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
    {
        log.info("登录用户:{} 已被删除.", username);
        throw new ServiceException("对不起,您的账号:" + username + " 已被删除");
    }
    else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
    {
        log.info("登录用户:{} 已被停用.", username);
        throw new ServiceException("对不起,您的账号:" + username + " 已停用");
    }

    passwordService.validate(user);

    return createLoginUser(user);
}

public UserDetails createLoginUser(SysUser user)
{
    return new LoginUser(user.getUserId(), user, permissionService.getMenuPermission(user));
}

PermissionService

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
 * 获取菜单数据权限
 *
 * @param user 用户信息
 * @return 菜单权限信息
 */
public Set<String> getMenuPermission(SysUser user)
{
    Set<String> perms = new HashSet<String>();
    // 管理员拥有所有权限
    if (user.isAdmin())
    {
        perms.add("*:*:*");
    }
    else
    {
        List<SysRole> roles = user.getRoles();
        if (!roles.isEmpty() && roles.size() > 1)
        {
            // 多角色设置permissions属性,以便数据权限匹配权限
            for (SysRole role : roles)
            {
                Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
                role.setPermissions(rolePerms);
                perms.addAll(rolePerms);
            }
        }
        else
        {
            perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
        }
    }
    return perms;
}

Mapper

1
2
3
4
5
6
7
8
<select id="selectMenuPermsByUserId" parameterType="Long" resultType="String">
select distinct m.perms
from sys_menu m
         left join sys_role_menu rm on m.menu_id = rm.menu_id
         left join sys_user_role ur on rm.role_id = ur.role_id
         left join sys_role r on r.role_id = ur.role_id
where m.status = '0' and r.status = '0' and ur.user_id = #{userId}
</select>

3.2 @PreAuthorize

SpringSecurity为我们提供了基于注解的权限控制方案,这也是我们项目中主要采用的方式。我们可以使用注解去指定访问对应的资源所需的权限。

但是要使用它我们需要先开启相关配置。

1
@EnableGlobalMethodSecurity(prePostEnabled = true)

然后就可以使用对应的注解:@PreAuthorize

SpringBoot - @PreAuthorize注解详解

3.3 自定义权限

3.2.1 注解如何使用?

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/**
 * 状态修改
 */
@PreAuthorize("@ss.hasPermi('system:user:edit')")
@Log(title = "用户管理", businessType = BusinessType.UPDATE)
@PutMapping("/status")
public ApiResponse changeStatus(@RequestBody SysUser user)
{
    userService.checkUserAllowed(user);
    SysUser sysUser = new SysUser();
    sysUser.setUserId(user.getUserId());
    sysUser.setStatus(user.getStatus());
    user.setUpdateBy(getUsername());
    return affectRows(userService.updateUserStatus(user));
}

3.2.2 自定义权限实现

@PreAuthorize("@ss.hasPermi('system:user:edit')")的意思是什么?

A. ss 是一个注册在 Spring容器中的Bean;

B. hasPermi 是PermissionService类中定义的方法;

C.当Spring EL 表达式返回True,则权限校验通过;

hasPermi方法中,获取当前用户权限loginUser.getPermissions(),与传入的permission作比较,看传入的permission是否在loginUserpermissions集合中

PermissionService.java的定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service("ss")
public class PermissionService{
    /** 所有权限标识 */
    private static final String ALL_PERMISSION = "*:*:*";

    /** 管理员角色权限标识 */
    private static final String SUPER_ADMIN = "admin";

    private static final String ROLE_DELIMETER = ",";

    private static final String PERMISSION_DELIMETER = ",";

    /**
     * 验证用户是否具备某权限
     * 
     * @param permission 权限字符串
     * @return 用户是否具备某权限
     */
    public boolean hasPermi(String permission)
    {
        if (StringUtils.isEmpty(permission))
        {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
        {
            return false;
        }
        PermissionContextHolder.setContext(permission);
        return hasPermissions(loginUser.getPermissions(), permission);
    }
}

3.2.3 如何使用原生的权限?

3.4 前端权限校验

3.4.1 自定义指令-Directives

使用vue的自定义指令-Directives实现前端根据权限决定是否渲染操作组件

创建操作权限Directive以及角色权限Directive

根据用户拥有的permissions以及roles决定是否渲染被相应自定义组件标注的操作组件

src/directive/permission/index.ts

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import type { Directive, DirectiveBinding } from "vue";
import useUserStore from "@/stores/modules/user";

/**
 * 操作权限处理
 */
export const hasPermi: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { value } = binding;
    const all_permission = "*:*:*";
    const store = useUserStore();
    const permissions = store.permissions;

    if (value && value instanceof Array && value.length > 0) {
      const permissionFlag = value;

      const hasPermissions = permissions.some((permission: string) => {
        return all_permission === permission || permissionFlag.includes(permission);
      });

      if (!hasPermissions) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`请设置操作权限标签值`);
    }
  }
};

/**
 * 角色权限处理
 */
export const hasRole: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { value } = binding;
    const super_admin = "admin";
    const store = useUserStore();
    const roles = store.roles;

    if (value && value instanceof Array && value.length > 0) {
      const roleFlag = value;

      const hasRole = roles.some((role: string) => {
        return super_admin === role || roleFlag.includes(role);
      });

      if (!hasRole) {
        el.parentNode && el.parentNode.removeChild(el);
      }
    } else {
      throw new Error(`请设置角色权限标签值"`);
    }
  }
};

3.4.2 全局注册自定义指令

src/directive/index.ts

1
2
3
4
5
6
7
8
import { hasRole, hasPermi } from "./permission";

import type { App } from "@vue/runtime-core";

export default function directive(app: App) {
  app.directive("hasRole", hasRole);
  app.directive("hasPermi", hasPermi);
}

main.ts

1
2
3
const app = createApp(App);
import directive from "./directive"; // directive
directive(app);

3.4.3 使用自定义指令

例如 v-hasPermi="['system:role:add']"

会根据当前用户是否具有 'system:role:add' 操作权限来决定是否渲染button

1
2
3
4
5
6
7
8
<el-button
  type="primary"
  plain
  icon="Plus"
  style="margin-right: 30px"
  @click="handleAdd"
  v-hasPermi="['system:role:add']"
>新增角色</el-button>