SpringSecurity(SpringBoot2.X版本实现)

资料来源于 SpringSecurity框架教程-Spring Security+JWT实现项目级前端分离认证授权

侵权删

目录

介绍

快速开始

认证

认证流程

登录校验流程

SpringSecurity完整流程

认证流程详解

代码实现

准备工作

mysql

mybatis-plus

redis

统一返回类

核心代码

密码加密存储

自定义登录接口

认证过滤器

退出登录

测试

授权

权限系统的作用

授权基本流程

授权实现

限制访问资源所需权限

封装权限信息

从数据库查询权限信息

RBAC权限模型

自定义失败处理

跨域

更多细节

其它权限校验方法

hasAuthority方法执行的源码

其他权限校验方法

自定义权限校验方法

基于配置的权限控制

CSRF

认证成功处理器

认证失败处理器

登出成功处理器



介绍

Spring Security 是一个功能强大且灵活的身份验证和访问控制框架,用于保护基于 Java 的企业应用程序。它提供了全面的安全解决方案,包括身份认证、授权、攻击防范、会话管理等功能,可以帮助开发者构建安全可靠的应用程序。

以下是 Spring Security 的一些主要特性和用途:

  1. 身份认证(Authentication):Spring Security 支持多种身份认证方式,包括基于表单、HTTP 基本认证、HTTP Digest 认证、OpenID、OAuth 等。开发者可以根据应用程序的需求选择合适的认证方式。

  2. 授权(Authorization):Spring Security 提供了灵活的授权机制,可以基于角色(Role)、权限(Permission)、表达式(Expression)等对用户进行访问控制。开发者可以根据应用程序的权限模型来定义授权规则,确保用户只能访问其具有权限的资源。

  3. 攻击防范:Spring Security 集成了各种安全防护机制,包括防止 CSRF(跨站请求伪造)、点击劫持、会话固定攻击、SQL 注入、XSS(跨站脚本攻击)等常见攻击。开发者可以通过配置简单的安全策略来保护应用程序免受这些攻击。

  4. 会话管理:Spring Security 提供了对用户会话的管理功能,包括基于内存、基于数据库、基于集群的会话管理等。开发者可以灵活地配置会话管理策略,确保用户会话的安全性和可靠性。

  5. 记住我(Remember Me):Spring Security 支持“记住我”功能,允许用户在下次访问应用程序时自动登录,而无需重新输入用户名和密码。

  6. 集成性:Spring Security 可以与 Spring 框架及其他常见的 Java Web 框架(如 Spring Boot、Spring MVC、Spring WebFlux)无缝集成,为开发者提供方便易用的安全解决方案。

总之,Spring Security 是一个功能强大、灵活且易于使用的安全框架,为 Java 开发者提供了全面的安全解决方案,帮助他们构建安全可靠的企业级应用程序。

而认证和授权也是SpringSecurity作为安全框架的核心功能,本文主要介绍SpringSecurity中认证和授权的基本操作。

快速开始

我们先简单实现一个基于SpringBoot2框架的SpringSecurity项目。

第1步:先在springboot项目的pom文件夹中加入SpringSecurity的依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-security</artifactId>
        </dependency>

第2步:创建一个后台接口

@RestController
@RequestMapping("/hello")
public class TestController {

    @GetMapping
    public String hello(){
        System.out.println("hello");
        return "hello";
    }
}

到这里,SpringSecurity的简单实现就完成了,是不是特别简单

然后我们尝试去访问后台的接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台,我们必须登陆之后才能对接口进行访问。

在浏览器输入:localhost:7000/hello,如果没有登录过,就会被SpringSecurity拦截跳到登录页面

 用户名默认为user,密码在控制台中给出

输入用户名密码之后才能正确访问我们自己定义的接口。

认证

认证流程

登录校验流程

SpringSecurity完整流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。

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

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

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

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

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

认证流程详解

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

AuthenticationManager接口:定义了认证Authentication的方法authenticate()

UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法,SpringSecurity初始是从内存中获取的,这个接口后面需要我们实现去自己的数据库中获取。

UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中,这个也是需要我们自己实现的。

代码实现

准备工作

mysql

从之前的分析我们可以知道,我们可以自定义一个UserDetailsService,让SpringSecurity使用我们的UserDetailsService。我们自己的UserDetailsService可以从数据库中查询用户名和密码。

创建一个数据库表

DROP TABLE IF EXISTS `user`;
CREATE TABLE `user`  (
  `id` int(11) NOT NULL COMMENT '用户Id',
  `name` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户名称',
  `password` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NOT NULL COMMENT '用户密码',
  `phone` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户手机号码',
  `email` varchar(255) CHARACTER SET utf8 COLLATE utf8_general_ci NULL DEFAULT NULL COMMENT '用户邮箱',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8 COLLATE = utf8_general_ci ROW_FORMAT = Dynamic;

在pom文件中引入对应依赖

        <dependency>
			<groupId>com.mysql</groupId>
			<artifactId>mysql-connector-j</artifactId>
		</dependency>
        <dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
		</dependency>

在配置文件中配置数据库信息

spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://xxxxxx:3306/test
    username: xxxx
    password: xxxx

定义实体类

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class User implements Serializable {
    private Integer id;
    private String name;
    private String password;
    private String phone;
    private String email;
}
mybatis-plus

导入依赖

<properties>
	<mybatis-plus.version>3.5.3.1</mybatis-plus.version>
</properties>
<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>mybatis-plus-boot-starter</artifactId>
	<version>${mybatis-plus.version}</version>
</dependency>

<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>mybatis-plus-generator</artifactId>
	<version>${mybatis-plus.version}</version>
</dependency>

<dependency>
	<groupId>com.baomidou</groupId>
	<artifactId>mybatis-plus-extension</artifactId>
	<version>${mybatis-plus.version}</version>
</dependency>

使用

定义mapper

@Mapper
public interface UserMapper extends BaseMapper<User> {
}

定义service

public interface UserService extends IService<User> {

}

定义serviceImpl

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{

}
redis

导入依赖

        <dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-data-redis</artifactId>
		</dependency>

在配置文件中配置redis连接信息

spring:
  redis:
    host: 47.115.217.159 #默认端口是6379就可以不写

重写redis的序列化器

//redis的序列化器
public class FastJsonRedisSerializer<T> implements RedisSerializer<T>
{

    public static final Charset DEFAULT_CHARSET = Charset.forName("UTF-8");

    private Class<T> clazz;

    static
    {
        ParserConfig.getGlobalInstance().setAutoTypeSupport(true);
    }

    public FastJsonRedisSerializer(Class<T> clazz)
    {
        super();
        this.clazz = clazz;
    }

    @Override
    public byte[] serialize(T t) throws SerializationException
    {
        if (t == null)
        {
            return new byte[0];
        }
        return JSON.toJSONString(t, SerializerFeature.WriteClassName).getBytes(DEFAULT_CHARSET);
    }

    @Override
    public T deserialize(byte[] bytes) throws SerializationException
    {
        if (bytes == null || bytes.length <= 0)
        {
            return null;
        }
        String str = new String(bytes, DEFAULT_CHARSET);

        return JSON.parseObject(str, clazz);
    }


    protected JavaType getJavaType(Class<?> clazz)
    {
        return TypeFactory.defaultInstance().constructType(clazz);
    }
}

 形式参数可能会爆红,不用管他,可以运行

@Configuration
public class RedisConfig {

    @Bean
    @SuppressWarnings(value = { "unchecked", "rawtypes" })
    public RedisTemplate<Object, Object> redisTemplate(RedisConnectionFactory connectionFactory)
    {
        RedisTemplate<Object, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);

        FastJsonRedisSerializer serializer = new FastJsonRedisSerializer(Object.class);

        // 使用StringRedisSerializer来序列化和反序列化redis的key值
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(serializer);

        // Hash的key也采用StringRedisSerializer的序列化方式
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(serializer);

        template.afterPropertiesSet();
        return template;
    }
}
统一返回类
public class R<T> {
    /**
     * 状态码
     */
    private Integer code;
    /**
     * 提示信息,如果有错误时,前端可以获取该字段进行提示
     */
    private String msg;
    /**
     * 查询到的结果数据,
     */
    private T data;

    public R(Integer code, String msg) {
        this.code = code;
        this.msg = msg;
    }

    public R(Integer code, T data) {
        this.code = code;
        this.data = data;
    }

    public Integer getCode() {
        return code;
    }

    public void setCode(Integer code) {
        this.code = code;
    }

    public String getMsg() {
        return msg;
    }

    public void setMsg(String msg) {
        this.msg = msg;
    }

    public T getData() {
        return data;
    }

    public void setData(T data) {
        this.data = data;
    }

    public R(Integer code, String msg, T data) {
        this.code = code;
        this.msg = msg;
        this.data = data;
    }
}



核心代码

创建一个类实现UserDetailsService接口,重写其中的方法。根据用户名从数据库中查询用户信息

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //获取用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getName, username));
        if(Objects.isNull(user)){
            throw new RuntimeException("用户不存在");
        }

        //权限信息

        //封装成UserDetails对象返回
        LoginUser loginUser = new LoginUser(user);
        return loginUser;
    }
}

因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。

@Data
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class LoginUser implements UserDetails {

    private User user;

    //返回用户的权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return null;
    }

    //返回用户的密码信息
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    //返回用户的用户名
    @Override
    public String getUsername() {
        return user.getName();
    }

    //用户的帐户是否未过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //用户的帐户是否未被锁定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //用户的凭据(密码)是否未过期
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //用户是否启用
    @Override
    public boolean isEnabled() {
        return true;
    }
}

注意:如果要测试,需要往用户表中写入用户数据,并且如果你想让用户的密码是明文存储,需要在密码前加{noop}。例如

这样登陆的时候就可以用“李四”作为用户名,1234作为密码来登陆了。

不过我们往数据库中存储密码肯定不会以明文形式存储的,后面我们会使用MD5加密的方式,也就不需要{noop}了。

密码加密存储

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

默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。

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

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

我们可以定义一个SpringSecurity的配置类,给容器中注入一个PasswordEncoder 的组件即可

@Configuration
public class SecurityConfig {

    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
    
}

自定义登录接口

接下我们需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。

在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。(形式参数可能会爆红,不用管他,运行没问题的)

@Configuration
public class SecurityConfig {

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}

认证成功的话要生成一个jwt,放入响应中返回。并且为了让用户下回请求时能通过jwt识别出具体的是哪个用户,我们需要把用户信息存入redis,可以把用户id作为key。

封装对应的JwtUtils

导入依赖

        <dependency>
			<groupId>com.auth0</groupId>
			<artifactId>java-jwt</artifactId>
			<version>4.3.0</version>
		</dependency>

编写配置类

public class JwtUtil {

    private static final String KEY = "随便写";
	
	//接收业务数据,生成token并返回
    public static String genToken(Map<String, Object> claims) {
        return JWT.create()
                .withClaim("claims", claims)
                .withExpiresAt(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 12))
                .sign(Algorithm.HMAC256(KEY));
    }

	//接收token,验证token,并返回业务数据
    public static Map<String, Object> parseToken(String token) {
        return JWT.require(Algorithm.HMAC256(KEY))
                .build()
                .verify(token)
                .getClaim("claims")
                .asMap();
    }

}

编写用户登录的controller类

@RestController
@RequestMapping("/user")
public class UserController {

    @Autowired
    private UserService userService;

    /**
     * 用户登录
     * @param user
     * @return
     */
    @PostMapping("/login")
    public R UserLogin(@RequestBody User user){
        return userService.userLogin(user);
    }
}

service实现类

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public R userLogin(User user) {
        UsernamePasswordAuthenticationToken authenticationToken=new UsernamePasswordAuthenticationToken(user.getName(),user.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        if(Objects.isNull(authenticate)){
            throw new RuntimeException("用户名或密码错误");
        }
        //使用userId生成token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String id = loginUser.getUser().getId().toString();
        String token = JwtUtil.genToken(new HashMap<>() {{
            put("id", id);
        }});
        //将用户信息存入redis
        redisTemplate.opsForValue().set("loginUser:"+id,loginUser);
        //将携带token的值返回给前端
        Map<String,String> map=new HashMap<>(){{put("token",token);}};
        return new R(200,"登录成功",map);
    }
}

认证过滤器

我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。

使用userid去redis中获取对应的LoginUser对象。

然后封装Authentication对象存入SecurityContextHolder

/**
 * 记得在security的配置文件中将这个过滤器配置在UsernamePasswordAuthenticationFilter之前执行
 */
@Configuration
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //先从请求头中获取token
        String token = request.getHeader("token");
        /**如果token不存在,说明用户发请求时没有携带token,那么可能就是用户没有登录,过滤器就放行,
         * 交给后面的FilterSecurityInterceptor进行处理
         **/
        if (StringUtils.isEmpty(token)) {
            filterChain.doFilter(request, response);
            //这里一定要return,因为在返回结果的时候也会来到这里,如果没有return,就会继续执行下面的代码
            return;
        }
        //解析token,得到token信息,去redis中获取信息
        Map<String, Object> map = JwtUtil.parseToken(token);
        String id = map.get("id").toString();
        LoginUser loginUser;
        try {
            loginUser = (LoginUser) redisTemplate.opsForValue().get("loginUser:" + id);
            if(Objects.isNull(loginUser)){
                //运行到这里,说明redis中不存在对应的用户信息,抛出一个错误
                throw new RuntimeException("用户不存在");
            }
        } catch (Exception e) {
            //运行到这里,说明redis中不存在对应的用户信息,抛出一个错误
            throw new RuntimeException("用户不存在");
        }

        //将用户信息存入SecurityContextHolder
        /**
         * TODO 获取权限信息封装到Authentication中
         * 如果过滤器执行到这里,说明前端携带了token,并且redis中也存在对应的信息,说明这个用户是已经登录过的,所以是已认证状态
         * 如果是已认证状态,UsernamePasswordAuthenticationToken(loginUser,null,null),这里第一个参数是用户信息,
         * 第三个参数是权限信息
         */
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

在SpringSecurity的过滤器链中将我们自己写的过滤器加进去,并且要在用户名密码校验器之前。

在SpringSecurity的配置文件中配置

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                // 关闭csrf
                .csrf().disable()
                //不通过Session获取SecurityContext
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                .antMatchers("/user/login").anonymous()//未登录才能访问这个请求
                .anyRequest().authenticated();
        /**
         * 将JwtAuthenticationTokenFilter过滤器配置到security过滤器链之中,并且在UsernamePasswordAuthenticationFilter
         * 之前执行
         */
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

退出登录

对于退出登录功能,我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService{

    @Autowired
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisTemplate redisTemplate;

    @Override
    public R userLogout() {
        //再security的context中获取当前登录的用户
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        //删除redis中对应的数据
        redisTemplate.delete("loginUser:"+loginUser.getUser().getId());
        return new R(200,"退出成功");
    }
}

测试

我这里准备了三个请求

登录请求

hello请求

退出登录请求

首先在没有登录的情况下发送hello请求

发送登录请求

将token加入hello请求的header中,再次发送

将token加入到logout请求的header中,发送请求

退出之后再次写到token发送hello请求

ok,到这里,认证流程就走完了。

授权

权限系统的作用

比如一个学校图书馆的管理系统,如果是普通学生登录就能看到借书还书相关的功能,不可能让他看到并且去使用添加书籍信息,删除书籍信息等功能。但是如果是一个图书馆管理员的账号登录了,应该就能看到并使用添加书籍信息,删除书籍信息等功能。

总结起来就是不同的用户可以使用不同的功能。这就是权限系统要去实现的效果。

我们不能只依赖前端去判断用户的权限来选择显示哪些菜单哪些按钮。因为如果只是这样,如果有人知道了对应功能的接口地址就可以不通过前端,直接去发送请求来实现相关功能操作。

所以我们还需要在后台进行用户权限的判断,判断当前用户是否有相应的权限,必须具有所需权限才能进行相应的操作。

授权基本流程

在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。

所以我们在项目中只需要把当前登录用户的权限信息也存入Authentication,然后设置我们的资源所需要的权限即可。

授权实现

限制访问资源所需权限

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

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

@SpringBootApplication
//开启限制访问资源权限
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class Application {

	public static void main(String[] args) {
		ConfigurableApplicationContext run = SpringApplication.run(Application.class, args);
		System.out.println(run);
	}

}

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

    @GetMapping
    //用户具有 admin 权限才能访问这个接口
    @PreAuthorize("hasAuthority('admin')")
    public String hello(){
        System.out.println("hello");
        return "hello";
    }

封装权限信息

我们前面在写UserDetailsServiceImpl的时候说过,在查询出用户后还要获取对应的权限信息,封装到UserDetails中返回。

我们先直接把权限信息写死封装到UserDetails中进行测试。

我们之前定义了UserDetails的实现类LoginUser,想要让其能封装权限信息就要对其进行修改

LoginUser.java中添加或修改如下代码

private List<String> permission;

    /**
     * 加上这个注解表示authorities这个属性不会进行序列化,
     * 因为我们后面需要将loginUser的对象序列化之后存储到redis中,
     * 但是对于GrantedAuthority类型的对象进行序列化会报错,所以排除这个属性
     */
    @JSONField(serialize = false)
    private List<GrantedAuthority> authorities;

    public LoginUser(User user, List<String> permission) {
        this.user = user;
        this.permission = permission;
    }


    //返回用户的权限信息
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //ctr+alt ---查询一个接口的事项类

        /**
         * 因为每一次框架内部调用这个方法都要进行转化,不如将authorities变成一个成员变量,并且加一个判断,
         * 不为空的时候直接返回即可,为空时再进行转化
         */
        if(!Objects.isNull(authorities)){
            return authorities;
        }

        /**
         * 把permissions中字符串类型的权限信息转换成GrantedAuthority对象存入authorities中
         */
        authorities = permission.stream().map((item) -> {
            GrantedAuthority authority = new SimpleGrantedAuthority(item);
            return authority;
        }).collect(Collectors.toList());
        return authorities;
    }

在UserServiceDetailService.java中修改成如下代码

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(wrapper);
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或密码错误");
        }
        //根据用户查询权限信息 添加到LoginUser中,这里先写死
        List<String> list = new ArrayList<>(Arrays.asList("test"));
        return new LoginUser(user,list);
    }
}

然后进行测试,第一次测试访问hello接口肯定是访问不到的,因为我们给用户设置的权限是test,hello接口需要admin权限才能请求。

把用户的权限设置成admin,就可以访问成功

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
        wrapper.eq(User::getUserName,username);
        User user = userMapper.selectOne(wrapper);
        if(Objects.isNull(user)){
            throw new RuntimeException("用户名或密码错误");
        }
        //根据用户查询权限信息 添加到LoginUser中,这里先写死
        List<String> list = new ArrayList<>(Arrays.asList("admin"));
        return new LoginUser(user,list);
    }
}

在实际应用中,用户的权限应该从数据库中获取,所以就引出了RBAC模型。

从数据库查询权限信息

RBAC权限模型

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

在数据库中定义这些表。(学习阶段的话也不用把表属性设置的太复杂,保留主要字段就行,下面的就比较复杂)




/*Table structure for table `sys_menu` */

DROP TABLE IF EXISTS `sys_menu`;

CREATE TABLE `sys_menu` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `menu_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '菜单名',
  `path` varchar(200) DEFAULT NULL COMMENT '路由地址',
  `component` varchar(255) DEFAULT NULL COMMENT '组件路径',
  `visible` char(1) DEFAULT '0' COMMENT '菜单状态(0显示 1隐藏)',
  `status` char(1) DEFAULT '0' COMMENT '菜单状态(0正常 1停用)',
  `perms` varchar(100) DEFAULT NULL COMMENT '权限标识',
  `icon` varchar(100) DEFAULT '#' COMMENT '菜单图标',
  `create_by` bigint(20) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(20) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `del_flag` int(11) DEFAULT '0' COMMENT '是否删除(0未删除 1已删除)',
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='菜单表';

/*Table structure for table `sys_role` */

DROP TABLE IF EXISTS `sys_role`;

CREATE TABLE `sys_role` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `name` varchar(128) DEFAULT NULL,
  `role_key` varchar(100) DEFAULT NULL COMMENT '角色权限字符串',
  `status` char(1) DEFAULT '0' COMMENT '角色状态(0正常 1停用)',
  `del_flag` int(1) DEFAULT '0' COMMENT 'del_flag',
  `create_by` bigint(200) DEFAULT NULL,
  `create_time` datetime DEFAULT NULL,
  `update_by` bigint(200) DEFAULT NULL,
  `update_time` datetime DEFAULT NULL,
  `remark` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='角色表';

/*Table structure for table `sys_role_menu` */

DROP TABLE IF EXISTS `sys_role_menu`;

CREATE TABLE `sys_role_menu` (
  `role_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '角色ID',
  `menu_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '菜单id',
  PRIMARY KEY (`role_id`,`menu_id`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4;

/*Table structure for table `sys_user` */

DROP TABLE IF EXISTS `sys_user`;

CREATE TABLE `sys_user` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `status` char(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) DEFAULT NULL COMMENT '邮箱',
  `phonenumber` varchar(32) DEFAULT NULL COMMENT '手机号',
  `sex` char(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) DEFAULT NULL COMMENT '头像',
  `user_type` char(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
  `create_by` bigint(20) DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(20) DEFAULT NULL COMMENT '更新人',
  `update_time` datetime DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8mb4 COMMENT='用户表';

/*Table structure for table `sys_user_role` */

DROP TABLE IF EXISTS `sys_user_role`;

CREATE TABLE `sys_user_role` (
  `user_id` bigint(200) NOT NULL AUTO_INCREMENT COMMENT '用户id',
  `role_id` bigint(200) NOT NULL DEFAULT '0' COMMENT '角色id',
  PRIMARY KEY (`user_id`,`role_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

根据用户ID查询用户权限的sql语句

SELECT 
	DISTINCT m.`perms`
FROM
	sys_user_role ur
	LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
	LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
	LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
WHERE
	user_id = 2
	AND r.`status` = 0
	AND m.`status` = 0

然后在代码中添加Menu对应的实体类以及Mapper文件,在UserDetailServiceImpl.java中把我们定死的那个权限修改成从数据库中查询即可,其他地方不需要改变。

Menu实体类(我在上面的sql建表语句中删除了一些字段,所以这里的实体类中的属性跟上面的sql建表语句中的对不上)

@TableName("sys_menu")
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Menu implements Serializable {

    private static final long serialVersionUID = -54979041104113736L;

    @TableId
    private Integer id;
    private String menuName;
    private String status;
    private String perms;
    private String delFlag;
    private String remark;

}

MenuMapper.java

@Mapper
public interface MenuMapper extends BaseMapper<Menu> {
    List<String> selectPermsByUserId(Long id);
}

MenuMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd" >
<mapper namespace="com.itheima.mapper.MenuMapper">

    <select id="selectPermsByUserId" resultType="java.lang.String">
        SELECT
            DISTINCT m.`perms`
        FROM
            sys_user_role ur
                LEFT JOIN `sys_role` r ON ur.`role_id` = r.`id`
                LEFT JOIN `sys_role_menu` rm ON ur.`role_id` = rm.`role_id`
                LEFT JOIN `sys_menu` m ON m.`id` = rm.`menu_id`
        WHERE
            user_id = #{userid}
          AND r.`status` = 0
          AND m.`status` = 0
    </select>

</mapper>

UserDetalisService.java

@Service
public class UserDetailServiceImpl implements UserDetailsService {

    @Autowired
    private UserMapper userMapper;

    @Autowired
    private MenuMapper menuMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //获取用户信息
        User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getName, username));
        if(Objects.isNull(user)){
            throw new RuntimeException("用户不存在");
        }

        //权限信息
        List<String> list = menuMapper.selectPermsByUserId(user.getId().longValue());

        //封装成UserDetails对象返回
        LoginUser loginUser = new LoginUser(user,list);
        return loginUser;
    }
}

自定义失败处理

我们还希望在认证失败或者是授权失败的情况下也能和我们的接口一样返回相同结构的json,这样可以让前端能对响应进行统一的处理。要实现这个功能我们需要知道SpringSecurity的异常处理机制。

在SpringSecurity中,如果我们在认证或者授权的过程中出现了异常会被ExceptionTranslationFilter捕获到。在ExceptionTranslationFilter中会去判断是认证失败还是授权失败出现的异常。

如果是认证过程中出现的异常会被封装成AuthenticationException然后调用AuthenticationEntryPoint对象的方法去进行异常处理。

如果是授权过程中出现的异常会被封装成AccessDeniedException然后调用AccessDeniedHandler对象的方法去进行异常处理。

所以如果我们需要自定义异常处理,我们只需要自定义AuthenticationEntryPoint和AccessDeniedHandler然后配置给SpringSecurity即可。

实现方式如下:

先引入一个工具类,用来渲染响应的数据格式

public class WebUtils
{
    /**
     * 将字符串渲染到客户端
     *
     * @param response 渲染对象
     * @param string 待渲染的字符串
     * @return null
     */
    public static String renderString(HttpServletResponse response, String string) {
        try
        {
            response.setStatus(200);
            response.setContentType("application/json");
            response.setCharacterEncoding("utf-8");
            response.getWriter().print(string);
        }
        catch (IOException e)
        {
            e.printStackTrace();
        }
        return null;
    }
}
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        R result = new R(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response,json);

    }
}

然后自定义实现类

//自定义授权失败处理
@Component
public class AccessDeniedHandlerImpl implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        R result = new R(HttpStatus.FORBIDDEN.value(), "权限不足");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response,json);

    }
}
//自定义失败处理
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ResponseResult result = new ResponseResult(HttpStatus.UNAUTHORIZED.value(), "认证失败请重新登录");
        String json = JSON.toJSONString(result);
        WebUtils.renderString(response,json);
    }
}

然后将这两个自定义实现类注入到Security的配置文件中

    @Autowired
    private AccessDeniedHandlerImpl accessDeniedHandler;

    @Autowired
    private AuthenticationEntryPointImpl authenticationEntryPoint;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //其他配置............................................

        //添加自定义异常处理器
        http.exceptionHandling()
                .accessDeniedHandler(accessDeniedHandler)
                .authenticationEntryPoint(authenticationEntryPoint);


        return http.build();
    }

跨域

浏览器出于安全的考虑,使用 XMLHttpRequest对象发起 HTTP请求时必须遵守同源策略,否则就是跨域的HTTP请求,默认情况下是被禁止的。 同源策略要求源相同才能正常进行通信,即协议、域名、端口号都完全一致。

前后端分离项目,前端项目和后端项目一般都不是同源的,所以肯定会存在跨域请求的问题。

所以我们就要处理一下,让前端能进行跨域请求。

对于springboot的跨域处理,大家可以去看我的这一篇文章

springboot中解决CORS的几种方式

这里可以使用这一种方式

@Configuration
public class CorsConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
      // 设置允许跨域的路径
        registry.addMapping("/**")
                // 设置允许跨域请求的域名
                .allowedOriginPatterns("*")
                // 是否允许cookie
                .allowCredentials(true)
                // 设置允许的请求方式
                .allowedMethods("GET", "POST", "DELETE", "PUT")
                // 设置允许的header属性
                .allowedHeaders("*")
                // 跨域允许时间
                .maxAge(3600);
    }
}

然后再Security的配置文件中开启CORS

@Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        //其他内容...............................

        http.cors();


        return http.build();
    }

更多细节

其它权限校验方法

我们前面都是使用@PreAuthorize注解,然后在其中使用的是hasAuthority方法进行校验。SpringSecurity还为我们提供了其它方法例如:hasAnyAuthority,hasRole,hasAnyRole等。

这里我们先不急着去介绍这些方法,我们先去理解hasAuthority的原理,然后再去学习其他方法你就更容易理解,而不是死记硬背区别。并且我们也可以选择定义校验方法,实现我们自己的校验逻辑。

hasAuthority方法实际是执行到了SecurityExpressionRoot的hasAuthority,大家只要断点调试既可知道它内部的校验原理。

它内部其实是调用authentication的getAuthorities方法获取用户的权限列表。然后判断我们存入的方法参数数据在权限列表中。

hasAuthority方法执行的源码

首先shift+shift点进搜索框,输入SecurityExpressionRoot,进入这个类

找到hasAuthority方法,打上端点,启动Security项目,进行调试

hasAuthority方法中又调用了一个方法,这个方法就在他下面 hasAnyAuthority

hasAnyAuthority方法调用了hasAnyAuthorName这个方法,我们继续追踪,看看这个方法里面的逻辑

debug进去之后,我们可以看到这个方法接收一个prefix,以及多个字符串的roles,我们在对应的接口方法中只校验一个,这里自然也只有一个

然后这个方法会调用getAuthoritySet这个方法,顾名思义,这个方法就是去得到当前登录用户的权限,继续追踪进这个方法

可以看到,这里使用了authentication.getAuthorities()来得到登录用户的权限信息。

大家还记不记得我们前面在我们自定义的一个过滤器中定义了如下代码

再结合LoginUser中的这个方法

大家就是不是就知道是怎么获取用户的权限信息的了。

继续debug

得到了用户的权限信息

这里的权限信息是我们从数据库中查询得到了,不要因为这里也是admin就跟上面接口的搞混了,这两个不是同一个对象哈。

继续debug

这一步将上面的用户权限信息封装为一个Set集合

继续debug

回到前面的这个hasAnyAuthorityName

这里就来判断用户的权限Set集合中是否包含接口上定义的权限,如果包含,返回true

这里肯定是包含的,所以就直接返回true,校验就通过了,当前用户就可以访问这个接口,后面debug过程的就是一些处理过程,与Security业务没什么关系,这里就不继续进行追踪了。

其他权限校验方法

hasAnyAuthority方法可以传入多个权限,只有用户有其中任意一个权限都可以访问对应资源。

    @PreAuthorize("hasAnyAuthority('admin','test','system:dept:list')")
    public String hello(){
        return "hello";
    }

hasRole要求有对应的角色才可以访问,但是它内部会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。

   @PreAuthorize("hasRole('system:dept:list')")
    public String hello(){
        return "hello";
    }

hasAnyRole 有任意的角色就可以访问。它内部也会把我们传入的参数拼接上 ROLE_ 后再去比较。所以这种情况下要用用户对应的权限也要有 ROLE_ 这个前缀才可以。

    @PreAuthorize("hasAnyRole('admin','system:dept:list')")
    public String hello(){
        return "hello";
    }
自定义权限校验方法

我们也可以定义自己的权限校验方法,在@PreAuthorize注解中使用我们的方法。

@Component("ex")
public class SGExpressionRoot {
​
    public boolean hasAuthority(String authority){
        //获取当前用户的权限
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        List<String> permissions = loginUser.getPermissions();
        //判断用户权限集合中是否存在authority
        return permissions.contains(authority);
    }
}

在SPEL表达式中使用 @ex相当于获取容器中bean的名字未ex的对象。然后再调用这个对象的hasAuthority方法

    @RequestMapping("/hello")
    @PreAuthorize("@ex.hasAuthority('system:dept:list')")
    public String hello(){
        return "hello";
    }

基于配置的权限控制

   我们也可以在配置类中使用使用配置的方式对资源进行权限控制。

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .antMatchers("/testCors").hasAuthority("system:dept:list222")

      
    }

CSRF

CSRF是指跨站请求伪造(Cross-site request forgery),是web常见的攻击之一。

CSRF攻击与防御(写得非常好)_注销账号可以防范csrf吗-CSDN博客

SpringSecurity去防止CSRF攻击的方式就是通过csrf_token。后端会生成一个csrf_token,前端发起请求的时候需要携带这个csrf_token,后端会有过滤器进行校验,如果没有携带或者是伪造的就不允许访问。

我们可以发现CSRF攻击依靠的是cookie中所携带的认证信息。但是在前后端分离的项目中我们的认证信息其实是token,而token并不是存储中cookie中,并且需要前端代码去把token设置到请求头中才可以,所以CSRF攻击也就不用担心了。

认证成功处理器

实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果登录成功了是会调用AuthenticationSuccessHandler的方法进行认证成功后的处理的。AuthenticationSuccessHandler就是登录成功处理器。

我们也可以自己去自定义成功处理器进行成功后的相应处理。

@Component
public class SGSuccessHandler implements AuthenticationSuccessHandler {
​
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("认证成功了");
    }
}
​
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Autowired
    private AuthenticationSuccessHandler successHandler;
​
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin().successHandler(successHandler);
​
        http.authorizeRequests().anyRequest().authenticated();
    }
}
​

认证失败处理器

实际上在UsernamePasswordAuthenticationFilter进行登录认证的时候,如果认证失败了是会调用AuthenticationFailureHandler的方法进行认证失败后的处理的。AuthenticationFailureHandler就是登录失败处理器。

我们也可以自己去自定义失败处理器进行失败后的相应处理。

@Component
public class SGFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        System.out.println("认证失败了");
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Autowired
    private AuthenticationSuccessHandler successHandler;
​
    @Autowired
    private AuthenticationFailureHandler failureHandler;
​
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                配置认证成功处理器
                .successHandler(successHandler)
//                配置认证失败处理器
                .failureHandler(failureHandler);
​
        http.authorizeRequests().anyRequest().authenticated();
    }
}
 

登出成功处理器

@Component
public class SGLogoutSuccessHandler implements LogoutSuccessHandler {
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        System.out.println("注销成功");
    }
}
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
​
    @Autowired
    private AuthenticationSuccessHandler successHandler;
​
    @Autowired
    private AuthenticationFailureHandler failureHandler;
​
    @Autowired
    private LogoutSuccessHandler logoutSuccessHandler;
​
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
//                配置认证成功处理器
                .successHandler(successHandler)
//                配置认证失败处理器
                .failureHandler(failureHandler);
​
        http.logout()
                //配置注销成功处理器
                .logoutSuccessHandler(logoutSuccessHandler);
​
        http.authorizeRequests().anyRequest().authenticated();
    }
}
 

结束........................................

  • 25
    点赞
  • 22
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值