Spring Security

https://gitee.com/peng-chuanbin/framework-learn.git

SpringSecurity:保护JavaWeb网站安全的框架

环境需求:

JDK1.8

SpringBoot2.7.6 版本下的 SpringSecurity版本为 5.7.5

阿里云脚手架

1.SpringSecurity结构(内部原理)

1.1 JavaWeb 内部结构

客户端(浏览器或者其他APP)发送请求,先经过多个过滤器,然后最后交给Servlet进行处理

在这里插入图片描述

1.2 SpringSecurity 内部结构

SpringSecurity的核心其实就是Filter

SecurityFilterChain 是SpringSecurity的默认过滤器链,这个过滤器链提供了拦截规则以及登录逻辑

在这里插入图片描述

2.SpringSecurity内置案例

官方内置了一套简单的验证逻辑,自带登录页面(登录功能)和注销以及内置账户和密码,方便开发者快速入门

2.1 环境准备

1.创建SpringBoot项目,添加依赖

SpringBoot版本采用的 2.7.6,Java开发环境基于JDK1.8

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

   <!-- SpringSecurity 测试依赖 -->
   <dependency>
       <groupId>org.springframework.security</groupId>
       <artifactId>spring-security-test</artifactId>
       <scope>test</scope>
   </dependency>

2.项目创建完成后,添加测试代码 HelloController.java,运行项目
通过浏览器 http://localhost:1002/FrameworkLearn/test1或者test2,查看效果

package com.learn.frameworklearn.controller;

@RestController
public class HelloController {

    //测试方法1  匿名访问
    @GetMapping(value = "/test1")
    public Result test1(){
        return Result.success("test1");
    }

    //测试方法2  认证后才能访问
    @GetMapping(value = "/test2")
    public Result test2(){
        return Result.success("test2");
    }

}

在这里插入图片描述

用户名:user

密码:控制台

在这里插入图片描述

注意:如果需要放开security拦截,不走security内置登录界面

spring:
  autoconfigure:
    exclude: org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration

2.2 内置案例详细介绍

SpringSecurity内置案例核心API

InMemoryUserDetailsManager:内置账户信息管理类,是UserDetailsService的子类

UserDetailsService:SpringSecurity用户信息管理类的核心接口,管理用户信息来源(数据库还是内存以及其他…)

UserDetails:SpringSecurity封装用户信息的核心接口,给SpringSecurity送用户信息时,SpringSecurity只认UserDetails

2.3 修改内置的用户名和密码

配置文件方式

spring:
  security:
    user:
      name: admin
      password: 123

3.替换系统自带用户名和密码的获取方式

3.1 认证的运行原理

在这里插入图片描述

3.2 替换默认生成的用户信息

不修改前端,只修改后端,从数据库拿账号密码(从数据库中获取账号密码传递给SpringSecurity上下文)

3.2.1 数据库表设计

创建securityrabc数据库,导入sql文件

-- 当前流行的权限控制系统 RBAC 模式,所以数据库表设计基于 RBAC

-- 用户表
CREATE TABLE user(
    user_id  BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID主键',
    phone  VARCHAR(50) NOT NULL UNIQUE   COMMENT '手机号,唯一',
    password  VARCHAR(255) NOT NULL  COMMENT '密码',
    usernameVARCHAR(100)  NOT NULL   COMMENT '用户名'
)AUTO_INCREMENT=1000 DEFAULT charset=utf8 COMMENT='用户表';

-- 角色表
CREATE TABLE role(
    role_id  BIGINT  NOT NULL  PRIMARY KEY AUTO_INCREMENT COMMENT '角色ID主键',
    role_name   VARCHAR(100)    NOT NULL  COMMENT '角色名'
)AUTO_INCREMENT=1000 DEFAULT charset=utf8 COMMENT='角色表';

-- 权限表
CREATE TABLE permission(
    permission_id     BIGINT  NOT NULL PRIMARY KEY  AUTO_INCREMENT COMMENT '权限ID主键',
    permission_name   VARCHAR(100)    NOT NULL                                        COMMENT '权限名'
)AUTO_INCREMENT=1000 DEFAULT charset=utf8 COMMENT='权限表';

-- 用户角色关联表
CREATE TABLE user_role(
    user_id     BIGINT  NOT NULL   COMMENT '用户ID',
    role_id     BIGINT  NOT NULL   COMMENT '角色ID',
    PRIMARY KEY (user_id,role_id)
) DEFAULT charset=utf8 COMMENT='用户角色关联表';

-- 角色权限关联表
CREATE TABLE role_permission(
    role_id             BIGINT  NOT NULL   COMMENT '角色ID',
    permission_id       BIGINT  NOT NULL   COMMENT '权限ID',
    PRIMARY KEY (role_id,permission_id)
) DEFAULT charset=utf8 COMMENT='角色权限关联表';
3.2.2 添加依赖
<!-- 数据库驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- ORM框架 -->
<dependency>
    <groupId>org.mybatis.spring.boot</groupId>
    <artifactId>mybatis-spring-boot-starter</artifactId>
    <version>2.3.0</version>
</dependency>
<!-- 省略GET/SET等工具类 -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
3.2.3 代码实现

1.配置文件(配置数据库,配置mybatis)

spring:
  # 数据源配置(Druid)
  datasource:
    type: com.alibaba.druid.pool.DruidDataSource
    driver-class-name: com.mysql.cj.jdbc.Driver

    druid:
      url: jdbc:mysql://127.0.0.1:3306/securityrabc?characterEncoding=utf-8
      username: root
      password: 123456

# MyBatis 配置
mybatis:
  # 别名包(自动为该包下类注册别名)
  typeAliasesPackage: com.learn.frameworklearn.**.entity
  # Mapper XML 文件位置
  mapperLocations: classpath:mybatis/**/*Mapper.xml
  configuration:
    map-underscore-to-camel-case: true # 驼峰命名

2.映射数据库表的实体类

/**
 * 数据库用户表 - user
 */
@Data
public class User {
  /**
   * 用户ID
   */
  private Long userId;
  /**
   * 手机号
   */
  private String phone;
  /**
   * 密码
   */
  private String password;
  /**
   * 用户名
   */
  private String username;
}

/**
 * 数据库角色表 - role
 */
@Data
public class Role {
  /**
   * 角色ID
   */
  private Long roleId;
  /**
   * 角色名称
   */
  private String roleName;
}

/**
 * 数据库权限表 - permission
 */
@Data
public class Permission {
  /**
   * 权限ID
   */
  private Long permissionId;
  /**
   * 权限名称
   */
  private String permissionName;
}

/**
 * 数据库用户角色关联表 - user_role
 */
@Data
public class UserRole {
  /**
   * 用户ID
   */
  private Long userId;
  /**
   * 角色ID
   */
  private Long roleId;
}

/**
 * 数据库角色权限关联表 - role_permission
 */
@Data
public class RolePermission {
  /**
   * 角色ID
   */
  private Long roleId;
  /**
   * 权限ID
   */
  private Long permissionId;
}

3.mapper层实现使用用户名获取用户信息的函数

在mapper包中新建一个UserMapper接口

package com.learn.frameworklearn.mapper;

/**
 * 用户表操作,使用手机号用来代替用户登录账号
 */
@Mapper
public interface UserMapper {
    /**
     * 通过手机号获取用户信息
     * phone 手机号
     * @return 用户信息
     */
    User getUserByPhone(String phone);
}

UserMapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.learn.frameworklearn.mapper.UserMapper">
    <select id="getUserByPhone" resultType="com.learn.frameworklearn.entity.User">
        SELECT * FROM user WHERE phone=#{phone}
    </select>
</mapper>

4.UserDetails接口实现,封装用户信息(给SpringSecurity送数据)

package com.learn.frameworklearn.security;

/**
 * 封装用户信息
 * SpringSecurity规定给他传递的用户信息,必须是UserDetails接口的子类实例对象进行封装
 */
@Data
public class LoginUserDetails implements UserDetails {

    private User user;

    public LoginUserDetails() {
    }

    public LoginUserDetails(User user) {
        this.user = user;
    }

    //当前账户的权限列表,暂时只实现认证,不实现授权,所以这边权限给空集合
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return new ArrayList<>();
    }

    //获取密码
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    //用户名
    @Override
    public String getUsername() {
        return user.getPhone();
    }

    //账号是否过期,在数据库中没有设置,给默认值不过期
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    //账号是否锁定,在数据库中没有设置,给默认值不锁定
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    //密码是否过期,在数据库中没有设置,给默认值不过期
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    //账号是否可用,在数据库中没有设置,给默认值可用
    @Override
    public boolean isEnabled() {
        return true;
    }
}

5.替换 InMemoryUserDetailsManager 实现 UserDetailsService方法

package com.learn.frameworklearn.security;

/**
 * UserDetailsService是Spring Security提供的从数据库获取数据的核心接口
 * 实现 UserDetailsService 重写里面的 loadUserByUsername方法,替换默认从内存中获取用户信息
 */
@Transactional
@Service
public class UserDetailsServiceImpl implements UserDetailsService {


    @Autowired
    private UserMapper userMapper;

    /**
     * 根据用户名查询用户信息

     * @return 不能直接返回user对象,必须返回UserDetails对象,所以需要重写一个UserDetails对象返回
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //通过用户名从数据库中查询用户信息(这个用户名从前端传递过来时可以使用手机号,邮箱或者其他用户账号)
        User user = userMapper.getUserByPhone(username);
        //判断当前账号是否存在
        if(Objects.isNull(user)){
            //如果为空,直接抛出异常
            throw new UsernameNotFoundException(username);
        }
        /*
         * 不为空说明数据库中存在,将信息送到SpringSecurity上下文中
         */
        return new LoginUserDetails(user);
    }

}

在这里插入图片描述

6.在数据库user表中添加测试账户

在这里插入图片描述

7.验证基于数据库的登录是否生效替换了默认用户名和密码的形式

验证是否生效
第一步: 启动项目
第二步: 在浏览器中输入 http://localhost:1002/FrameworkLearn/test1 服务会自动跳转到SpringSecurity的内置登录页面
第三步: 数据在数据库中自己添加的用户信息
第四步: 查看现象
注意: 肯定会失败,因为密码,因为密码在数据库中是明文的,需要设置明文规则,如果在密码前添加{noop},可以不需要进行加密(security默认密码是加密的)

修改密码后,浏览器中继续测试,就会成功

在这里插入图片描述

8.如果想数据库中的密码是密文,可以使用 BCryptPasswordEncoder 进行加密和解密

创建配置类

将 BCryptPasswordEncoder 加入到IOC容器中,SpringSecurity 自动生效

package com.learn.frameworklearn.security;

/**
 * Spring Security配置类
 */
@Configuration
public class SecurityConfig {
    
    /**
     * 配置加密工具
     */
    @Bean
    public PasswordEncoder encoder(){
        return new BCryptPasswordEncoder();
    }
    
}

在这里插入图片描述

编写测试代码,将明文密码加密成密文,保存到数据库中,然后在继续使用浏览器测试

在这里插入图片描述

替换数据库中的密码

注意:数据库中的密码不能明文存储,要加密,而且对于加密工具,只有加密没有解密的说法,不能把加密后的密码资再解密成123456,没有这种做法

参考:市面上大部分软件的忘记密码功能,都是修改密码,再重新登录,而不是真正的找回密码

在这里插入图片描述

4.替换页面登录为前后端分离方式

修改前端界面(封装前端认证信息传递给SpringSecurity上下文)

前后端分离架构是当前最流行的软件架构模式,下面需要修改SpringSecurity默认的逻辑,改成成前后端分离的架构模式

1.替换掉登陆页面

2.修改从内置登录页面获取用户名和密码的逻辑

3.修改内部默认跳转登录页面的逻辑

4.修改登录失败的逻辑

4.1 替换内置登录验证逻辑

4.1.1 登录验证流程图

此流程图是基于前后端不分离的表单流程图,和前后端分离的流程基本上一模一样,后面实现前后端分离的登录,参考此流程

在这里插入图片描述

4.1.2 登录核心API介绍

SecurityFilterChain:SpringSecurity核心过滤器,SpringSecurity默认会自动创建一个此对象,用来支持自带的登录,现在采用前后端分离,登录逻辑发生变化,所以需要我们自己创建SpringSecurity来覆盖默认的

UsernamePasswordAuthenticationToken:封装前端页面传递过来的用户名和密码,封装好后通过

AuthenticationManager传递给SpringSecurity上下文

AuthenticationManager:SpringSecurity的认证管理器,用来进行认证

Authentication:认证实例,认证成功后,里面封装认证成功后的信息

4.1.3 登录逻辑代码实现

配置认证管理器实例

package com.learn.frameworklearn.security;

@Configuration
public class SecurityConfig {

    /**
     * SpringSecurity 认证管理器
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

}

配置 SecurityFilterChain

package com.learn.frameworklearn.security;

/**
 * Spring Security配置类
 */
@Configuration
public class SecurityConfig {

    /**
     * 配置加密工具
     */
    @Bean
    public PasswordEncoder encoder(){
        return new BCryptPasswordEncoder();
    }


    /**
     * SpringSecurity 认证管理器
     */
    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
        return authenticationConfiguration.getAuthenticationManager();
    }

    /**
     * SpringSecurity过滤器
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() //防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //取消session
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll() //这个/login地址,登陆和未登录的人都可以访问访问
                .anyRequest().authenticated(); //除了上面设置的地址可以匿名访问,其它所有的请求地址需要认证访问
        return http.build();
    }


}

登录逻辑实现

@RestController
public class LoginController {
    
    @Resource
    private AuthenticationManager authenticationManager;
    
 	/**
     * 登录
     * phone 手机号
     * password 密码
     * @return 响应结果
     */
    @PostMapping(value = "/login")
    public Result login(String phone, String password){
        /*
         * SpringSecurity 的认证逻辑
         * 默认情况SpringSecurity内置了登录页面,内置了从页面获取数据,并将其数据送到SpringSecurity上下文的方式
         * 当前前后端分离的逻辑,数据不再从页面获取,所以不能再使用内置逻辑,需要程序员自己实现将数据送到SpringSecurity上下文中
         */
        //封装用户名(手机号作为用户名)和密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(phone,password);
        //调用认证管理中的认证方法,成功就封装用户的全部信息,但是调用后可能出现异常,所以需要try...catch
        try {
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            //如果认证成功Authentication中就会有用户信息,否则为空
            if(Objects.isNull(authenticate)){
                //认证失败
                return Result.error(301,"认证失败,用户名或密码错误");
            }
        }catch (RuntimeException e){
            e.printStackTrace();
            //认证失败
            return Result.error(302,"认证失败,用户名或密码错误");
        }
        return Result.success();
    }
    
}

代码完成后进行测试,因为没有前端页面所以需要一个客户端工具进行测试,这里采用postman进行测试

注意:.antMatchers(“/login”).permitAll() //这个/login地址,登陆和未登录的人都可以访问访问

在这里插入图片描述

在这里插入图片描述

还有另外一种情况,在登录了的情况下,访问了其它资源,例如: http://localhost:1002/FrameworkLearn/test2
注意: 我前面已经登陆了为什么还不能访问,因为前后端分离项目不是Session-Cookie机制

在前后端分离的项目中,传统的Session-Cookie机制可能不再适用,因为前端和后端是独立运行的。在这种情况下,通常会使用其他认证和授权机制,如JWT(JSON Web Tokens)或OAuth2等

在这里插入图片描述

先写一个不携带token访问其他资源的逻辑处理

4.2 修改未登录访问其它资源的逻辑处理

实现此功能需要的步骤如下:

第二步: 实现AuthenticationEntryPoint接口,实现自定义的处理器

第三步: 将自定义的处理器注册到Spring Security的核心Filter中

第四步: 测试是否生效

第一步: 添加依赖(json工具依赖)

<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.32</version>
</dependency>

第二步

package com.learn.frameworklearn.security;

/**
 * 匿名请求访问私有化请求时的处理器(直接告诉用户未认证)
 * 在未认证或者认证错误的情况下访问需要认证的资源时的处理类
 */
@Component  //加入到IOC容器
public class LoginUnAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
    /*
     * 当访问一个需要认证的资源时因为当前用户没有认证或者认证失败,直接访问资源会交给此函数进行处理
     * 因为架构是前后端分离的项目,所以给客户端的提示保持和控制器的返回值格式相同
     */
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json");
        Result result = Result.error(303, "用户未认证或登录已过期,请重新登录后再访问");
        //将消息json化
        String json = JSONUtil.toJsonStr(result);
        //送到客户端
        response.getWriter().print(json);
    }
}

第三步

将自定义的处理器注册到Spring Security的核心过滤器中

package com.learn.frameworklearn.security;

@Configuration
public class SecurityConfig {

    //注入进来
    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;
    /**
     * SpringSecurity过滤器
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() //防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //取消session
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll() //这个/login地址,登陆和未登录的人都可以访问访问
                .anyRequest().authenticated(); //除了上面设置的地址可以匿名访问,其它所有的请求地址需要认证访问

        //自定义未登录处理(注册匿名请求访问私有化请求时的处理器) 添加这个
      http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
        return http.build();
    }


}

第四步:测试

在这里插入图片描述

4.3 解决HTTP协议无状态

传统前后端不分离的架构,采用Session+Cookie机制(解决HTTP协议无状态)

现在前后端分离架构,采用token令牌方式

token生成采用JWT工具生成和校,使用Redis数据库进行校验和保存

4.3.1 Redis客户端安装和启动

省略

3.2 JWT工具类编写

添加依赖

<!-- JWT工具类 -->
<dependency>
  <groupId>com.auth0</groupId>
  <artifactId>java-jwt</artifactId>
  <version>4.4.0</version>
</dependency>

<!-- redis客户端 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

工具类代码实现

package com.learn.frameworklearn.utils;

/**
 * JWT工具类
 */
public class JwtUtils {
    private static Algorithm hmac256 = Algorithm.HMAC256("YLWTSMTJFYHDCMGSCWHSSYBZSDKC");
    /**
     * 生成token
     * @param pub  负载
     * @param expiresTime 过期时间(单位 毫秒)
     * @return token
     */
    public static String sign(String pub, Long expiresTime){
        return JWT.create() //生成令牌函数
                .withIssuer(pub) //自定义负载部分,其实就是添加Claim(jwt结构中的payload部分),可以通过源码查看
                .withExpiresAt(new Date(System.currentTimeMillis()+expiresTime)) //添加过期时间
                .sign(hmac256);
    }
    /**
     * 校验token
     */
    public static boolean verify(String token){
        JWTVerifier verifier = JWT.require(hmac256).build();
        //如果正确,直接代码向下执行,如果错误,抛异常
        verifier.verify(token);
        return true;
    }
    /**
     * 从token中获取负载
     * @param token 令牌
     * @return 保存的负载
     */
    public static String getClaim(String token){
        DecodedJWT jwt = JWT.decode(token);
        Claim iss = jwt.getClaim("iss");
        return iss.asString();
    }
}
4.3.3 Redis客户端实现

配置文件修改

spring:
  redis:
    host: 127.0.0.1  # redis服务器地址
    password:        # redis服务器密码,我这里没有设置密码
    database: 0      # redis的库,我这里用0号库

代码实现

package com.learn.frameworklearn.utils;

/**
 * Redis客户端
 */
@Component
public class RedisClient {
    @Resource
    private StringRedisTemplate stringRedisTemplate;
    /**
     * 保存数据
     */
    public void set (String key,String value){
        stringRedisTemplate.opsForValue().set(key,value);
    }
    /**
     * 保存数据-过期时间
     * @param key    键
     * @param value  值
     * @param time   过期时间,单位是 毫秒
     */
    public void set (String key,String value,Long time){
        stringRedisTemplate.opsForValue().set(key,value,time, TimeUnit.MILLISECONDS);
    }
    /**
     * 通过键获取对应的值
     * @param key 键
     * @return    值
     */
    public String get(String key){
        return stringRedisTemplate.opsForValue().get(key);
    }
    /**
     * 通过键删除对应的值
     * @param key 键
     */
    public void del(String key){
        stringRedisTemplate.delete(key);
    }
    /**
     * 判断key是否存在
     */
    public Boolean exists(String key){
        return stringRedisTemplate.hasKey(key);
    }
}
4.3.4 登录逻辑修改
package com.learn.frameworklearn.controller;

@RestController
public class LoginController {

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private RedisClient redisClient;

    /**
     * 登录
     * phone 手机号
     * password 密码
     *
     * @return 响应结果
     */
    @PostMapping(value = "/login")
    public Result login(String phone, String password) {
        /*
         * SpringSecurity 的认证逻辑
         * 默认情况SpringSecurity内置了登录页面,内置了从页面获取数据,并将其数据送到SpringSecurity上下文的方式
         * 当前前后端分离的逻辑,数据不再从页面获取,所以不能再使用内置逻辑,需要程序员自己实现将数据送到SpringSecurity上下文中
         */
        //1.封装用户名(手机号作为用户名)和密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(phone, password);
        //2.调用认证管理中的认证方法,成功就封装用户的全部信息,但是调用后可能出现异常,所以需要try...catch
        try {
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            //如果认证成功Authentication中就会有用户信息,否则为空
            if (Objects.isNull(authenticate)) {
                //认证失败
                return Result.error(301, "认证失败,用户名或密码错误");
            }
            //3.登录成功将用户信息保存到redis中,以token作为key
            //获取用户信息
            LoginUserDetails principal = (LoginUserDetails) authenticate.getPrincipal();
            if (Objects.isNull(principal)) {
                return Result.error(303,"认证失败,用户名或密码错误");
            }
            //使用token作为redis的key,格式为 login:token
            String token = JwtUtils.sign(principal.getUsername(), 1000 * 60 * 60 * 24 * 7L);//过期时间为7天
            String key = "login:token:" + token;
            //将用户信息json化
            String json = JSONUtil.toJsonStr(principal);
            //将用户信息json化后保存到redis中
            redisClient.set(key, json, 1000 * 60 * 60 * 24 * 7L);
            //4.将token返回给客户端(前端)
            Map<String, Object> map = new HashMap<>();
            map.put("token", token);
            return Result.success(map);
        } catch (RuntimeException e) {
            e.printStackTrace();
            //认证失败
            return Result.error(302, "认证失败,用户名或密码错误");
        }


    }
}

测试,查看是否生成token,并且会把这个token存储在redis中

在这里插入图片描述

redis客户端工具查看

在这里插入图片描述

注意:之后的每次请求,都要带着token一起发送请求

4.3.5 反复登录,多token解决

客户端发送的所有请求都需要带token,在进行登录时单独进行token的校验,如果登陆过,刷新token

在登录中添加一个校验逻辑,删除原来的key

package com.learn.frameworklearn.controller;

@RestController
public class LoginController {

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private RedisClient redisClient;

    /**
     * 登录
     */
    @PostMapping(value = "/login")
    public Result login(String phone, String password, HttpServletRequest request) {
        
        //添加这个方法
        /*
         * 登录前判断是否上次的登录未过期,如果未过期直接删除,重新登录生成新token
         * 就是刷新token,登录一次刷新一次
         */
        String token_ = request.getHeader("token");
        //判断token是否存在
        if (StringUtils.hasText(token_)) {
            //判断redis中是否存在
            String claim = JwtUtils.getClaim(token_);
            //校验是否是同一个账户
            if (StringUtils.hasText(claim) && claim.equals(phone)) {
                String key = "login:token:" + token_;
                //从redis中删除
                redisClient.del(key);
            }
        }

        //......

    }
}
4.3.6 其它资源访问基于token令牌方式

使用令牌方式,替换前后端不分离的session-cookie机制,解决HTTP无状态的问题

SpringSecurity运行原理

pringSecurity的功能实现:SpringSecurity的核心是过滤器链,核心功能实现也是由一个个Filter(过滤器)组成

下面是SpringSecurity内置的过滤器

在这里插入图片描述

会经过一系列的过滤器链
在这里插入图片描述

简单画了一个SpringSecurity原理图,客戶端发送过来的请求会经过一个个的过滤器,每一个过滤器承担着不同的功能
例如,UsernamePasswordAuthenticationFilter过滤器帮助我们校验账户和密码

现在我们要模拟session+cookie机制,通过token凭据实现权限控制,方式如下

在这里插入图片描述

4.3.7 基于token机制的实现

第一步: 自定义 Filter

第二步: 注册到SpringSecurity的Filter中

第一步

package com.learn.frameworklearn.security;

/**
 * 自定义过滤器,实现token令牌的判断(统一校验token凭证)
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
    @Resource
    private RedisClient redisClient;
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        //获取请求头token
        String token = request.getHeader("token");
        if(StringUtils.hasLength(token)){
            //redis中获取用户信息
            String key = "login:token:"+token;
            String json = redisClient.get(key);
            if(StringUtils.hasLength(json)){
                //反序列化
                LoginUserDetails user = JSONUtil.toBean(json, LoginUserDetails.class);
                if(Objects.nonNull(user)){
                    //封装用户信息,送到下一个过滤器  UsernamePasswordAuthenticationFilter
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user,null,user.getAuthorities());
                    //将Redis数据库中的信息送到SpringSecurity上下文中
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }else {
                    SecurityContextHolder.getContext().setAuthentication(null);
                }
            }
        }
        //放行,后面交给Spring Security 框架
        filterChain.doFilter(request,response);
    }
}

第二步

package com.learn.frameworklearn.security;

@Configuration
public class SecurityConfig {

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;

    //注入进来
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    /**
     * SpringSecurity过滤器
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() //防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //取消session
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll() //这个/login地址,登陆和未登录的人都可以访问访问
                .anyRequest().authenticated(); //除了上面设置的地址可以匿名访问,其它所有的请求地址需要认证访问

        //将自定义的过滤器注册到SpringSecurity过滤器链中,并且设置到UsernamePasswordAuthenticationFilter前面     添加这句话
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //自定义未登录处理(注册匿名请求访问私有化请求时的处理器)
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
        return http.build();
    }


}

测试验证

在这里插入图片描述

在这里插入图片描述

5.注销

注销比较简单,直接将redis数据清空了即可

SpringSecurity内置注销功能logout,我们使用内置的注销,覆盖注销的逻辑即可

package com.learn.frameworklearn.security;

/**
 * 注销成功的处理器
 */
@Component
public class LogoutStatusSuccessHandler implements LogoutSuccessHandler {
    @Resource
    private RedisClient redisClient;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        String token = request.getHeader("token");
        if(StringUtils.hasText(token)){
            redisClient.del("login:token:"+token);
        }
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json");
        Result result = Result.success("注销成功");
        //将消息json化
        String json = JSONUtil.toJsonStr(result);
        //送到客户端
        response.getWriter().print(json);
    }
}

在security中注册一下注销成功的处理器

package com.learn.frameworklearn.security;

@Configuration
public class SecurityConfig {

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Resource
    private LogoutStatusSuccessHandler logoutStatusSuccessHandler;

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() //防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //取消session
                .and()
                .authorizeRequests()
                .antMatchers("/login").permitAll() //这个/login地址,登陆和未登录的人都可以访问访问
                .anyRequest().authenticated(); //除了上面设置的地址可以匿名访问,其它所有的请求地址需要认证访问

        //将自定义的过滤器注册到SpringSecurity过滤器链中,并且设置到UsernamePasswordAuthenticationFilter前面
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //自定义未登录处理(注册匿名请求访问私有化请求时的处理器)
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
        
        //注册自定义处理器(注销处理器,注销成功后删除redis中的数据)  添加这句话
        http.logout().logoutSuccessHandler(logoutStatusSuccessHandler);

        return http.build();
    }

}

http://localhost:1002/FrameworkLearn/logout,使用security内置的即可,改一下逻辑

在这里插入图片描述

6.授权

授权的意义就是当一个用户登录成功后,此账户本身拥有的权限

eg:当前用户用哪些角色,当前角色都能干什么(删除、更新等)

在这里插入图片描述

6.1 将权限从数据库送到SpringSecurity上下文

从数据库中将用户信息送到上下文,在 UserDetailsService接口的实现类中

实现步骤:

第一步:mapper提供查询方法,将用户相关的权限信息查询到封装到UserDetails的对象中
通过用户ID查询角色名称列表
通过角色ID查询权限名称列表

第二步:在UserDetailsService中进行封装
在UserDetailsService中调用mapper并且封装传递给 SpringSecurity 上下文,修改UserDetails结构添加属性等

第三步:在控制器层进行注解控制(配置文件)

第四步:开启注解配置否则不生效

第五步:权限不够的处理器

6.1.1 第一步
/**
 * 用户角色关联表
 */
@Mapper
public interface UserRoleMapper {
    /**
     * 通过用户ID查询用户角色列表
     * @param userId 用户ID
     * @return 用户角色列表
     */
    List<UserRole> getUserRolesByUserId(Long userId);
}

/**
 * 操作数据库角色表
 */
@Mapper
public interface RoleMapper {
    /**
     * 批量查询
     * @param roleIds 角色ID列表
     * @return 角色列表
     */
    List<Role> batchGetRolesByRoleIds(List<Long> roleIds);
}

/**
 * 操作数据库角色权限表
 */
@Mapper
public interface RolePermissionMapper {

    /**
     * 通过角色ID列表查询权限角色权限列表
     * @param roleIds 角色IDs
     * @return 角色权限列表
     */
    List<RolePermission> getRolePermissionsByRoleIds(List<Long> roleIds);
}

/**
 * 操作数据库权限表
 */
@Mapper
public interface PermissionMapper {
    /**
     * 通过权限ID列表查询权限列表
     * @param permissionIds 权限ID列表
     * @return 权限列表
     */
    List<Permission> batchGetPermissionsByPermissionIds(List<Long> permissionIds);
}

mapper.xml

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.learn.frameworklearn.mapper.PermissionMapper">
    <select id="batchGetPermissionsByPermissionIds" resultType="com.learn.frameworklearn.entity.Permission">
        SELECT * FROM permission WHERE permission_id IN
        <foreach collection="list" open="(" close=")" separator="," item="item">
            #{item}
        </foreach>
    </select>
</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.learn.frameworklearn.mapper.RolePermissionMapper">
    <select id="batchGetRolePermissionsByRoleIds" resultType="com.learn.frameworklearn.entity.RolePermission">
        SELECT * FROM role_permission WHERE role_id IN
        <foreach collection="list" open="(" close=")" separator="," item="item">
            #{item}
        </foreach>
    </select>
</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.learn.frameworklearn.mapper.RoleMapper">
    <select id="batchGetRolesByRoleIds" resultType="com.learn.frameworklearn.entity.Role">
        SELECT * FROM role WHERE role_id IN
        <foreach collection="list" open="(" close=")" separator="," item="item">
            #{item}
        </foreach>
    </select>
</mapper>

<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.learn.frameworklearn.mapper.UserRoleMapper">
    <select id="getUserRoleByUserId" resultType="com.learn.frameworklearn.entity.UserRole">
        SELECT * FROM user_role WHERE user_id=#{userId}
    </select>
</mapper>

在这里插入图片描述

6.1.2 第二步

UserDetails 修改

package com.learn.frameworklearn.security;

/**
 * 封装用户信息
 * SpringSecurity规定给他传递的用户信息,必须是UserDetails接口的子类实例对象进行封装
 */
@Data
public class LoginUserDetails implements UserDetails {

    private User user;

    //角色名称列表,用于授权
    private List<String> roleNames;
    //权限名称列表,用户授权
    private List<String> permissionNames;

    public LoginUserDetails() {
    }

    public LoginUserDetails(User user, List<String> roleNames, List<String> permissionNames) {
        this.user = user;
        this.roleNames = roleNames;
        this.permissionNames = permissionNames;
    }

    //当前账户的权限列表   现在写这里
       @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {

        List<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        /*
         * 当前用户的权限信息
         * 1. 角色表权限   ROLE_  admin  ROLE_admin
         * 2. 权限表权限  del  add query edit
         */
        //添加角色
        if(!CollectionUtils.isEmpty(roleNames)){
            //将角色设置到GrantedAuthority中,官网要求角色要加上前缀 ROLE_xxx 区分其它权限
            for (String roleName : roleNames) {
                roleName = "ROLE_"+roleName;
                grantedAuthorities.add(new SimpleGrantedAuthority(roleName));
            }
        }
        //添加权限
        if(!CollectionUtils.isEmpty(permissionNames)){
            //将权限设置到GrantedAuthority中
            for (String permissionName : permissionNames) {
                grantedAuthorities.add(new SimpleGrantedAuthority(permissionName));
            }
        }
        return grantedAuthorities;
    }

    //获取密码
    @Override
    public String getPassword() {
        return user.getPassword();
    }

    //......
}

UserDetailsService修改

package com.learn.frameworklearn.security;

/**
 * UserDetailsService是Spring Security提供的从数据库获取数据的核心接口
 * 实现 UserDetailsService 重写里面的 loadUserByUsername方法,替换默认从内存中获取用户信息
 */
@Transactional
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;
    @Resource
    private UserRoleMapper userRoleMapper;
    @Resource
    private RoleMapper roleMapper;
    @Resource
    private RolePermissionMapper rolePermissionMapper;
    @Resource
    private PermissionMapper permissionMapper;

    /**
     * 根据用户名查询用户信息
     *
     * @param username
     * @return 不能直接返回user对象,必须返回UserDetails对象,所以需要重写一个UserDetails对象返回
     */
    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //通过用户名从数据库中查询用户信息(这个用户名从前端传递过来时可以使用手机号,邮箱或者其他用户账号)
        User user = userMapper.getUserByPhone(username);
        //判断当前账号是否存在
        if (Objects.isNull(user)) {
            //如果为空,直接抛出异常
            throw new UsernameNotFoundException(username);
        }
        
        //添加这里
        /*
         * 不为空说明数据库中存在,将信息送到SpringSecurity上下文中(用户信息,角色信息,权限信息都要返回给客户端)
         */
        //角色名称列表
        List<String> roleNames = new ArrayList<>();
        //权限名称列表
        List<String> permissionNames = new ArrayList<>();

        //获取用户角色列表
        List<UserRole> userRoles = userRoleMapper.getUserRoleByUserId(user.getUserId());
        if (!CollectionUtils.isEmpty(userRoles)) {
            //查询角色信息
            List<Long> roleIds = userRoles.stream().map(UserRole::getRoleId).collect(Collectors.toList());
            if (!CollectionUtils.isEmpty(roleIds)) {
                //查询角色信息
                List<Role> roles = roleMapper.batchGetRolesByRoleIds(roleIds);
                if (!CollectionUtils.isEmpty(roles)) {
                    List<String> roleNameList = roles.stream().map(Role::getRoleName).collect(Collectors.toList());
                    roleNames.addAll(roleNameList);
                }

                //当前角色的权限列表
                List<RolePermission> rolePermissions = rolePermissionMapper.batchGetRolePermissionsByRoleIds(roleIds);
                if (!CollectionUtils.isEmpty(rolePermissions)) {
                    //权限id列表
                    List<Long> permissionIdList = rolePermissions.stream().map(RolePermission::getPermissionId).collect(Collectors.toList());
                    if (!CollectionUtils.isEmpty(permissionIdList)) {
                        //查询权限信息
                        List<Permission> permissions = permissionMapper.batchGetPermissionsByPermissionIds(permissionIdList);
                        if (!CollectionUtils.isEmpty(permissions)) {
                            List<String> permissionNameList = permissions.stream().map(Permission::getPermissionName).collect(Collectors.toList());
                            if (!CollectionUtils.isEmpty(permissionNameList)) {
                                //权限名称列表
                                permissionNames.addAll(permissionNameList);
                            }
                        }
                    }
                }
            }
        }
        //用户信息,角色信息,权限信息都要返回给客户端
        return new LoginUserDetails(user, roleNames, permissionNames);
    }
}
6.1.3 第三步

在配置文件上添加注解,开启注解校验

/**
 * 测试控制器
 * 验证SpringSecurity是否生效
 */
@RestController
public class HelloController {

    //测试方法1  匿名访问(pom.xml中注释掉security的依赖就可以随便访问,添加了依赖,访问网址就会默认跳转到内置网页)
    @GetMapping(value = "/test1")
    public Result test1(){
        return Result.success("test1");
    }

    //测试方法2  认证后才能访问
    @GetMapping(value = "/test2")
    public Result test2(){
        return Result.success("test2");
    }

    //测试方法3  admin角色可以访问
    @PreAuthorize(value = "hasRole('admin')") //开启@EnableMethodSecurity(securedEnabled = true)测试
    @GetMapping(value = "/test3")
    public Result test3(){
        return Result.success("test3");
    }
    //测试方法4 cto角色或者cfo角色可以访问
    @PreAuthorize(value = "hasAnyRole('cfo','cto')")
    @GetMapping(value = "/test4")
    public Result test4(){
        return Result.success("test4");
    }
    //测试方法5 cto角色和cfo角色可以访问
    @PreAuthorize(value = "hasRole('cto') and hasRole('admin')")
    @GetMapping(value = "/test5")
    public Result test5(){
        return Result.success("test5");
    }

    //测试方法6 del权限可以访问
    @PreAuthorize(value = "hasAuthority('del')")
    @GetMapping(value = "/test6")
    public Result test6(){
        return Result.success("test6");
    }

    //测试方法7 del或者edit权限可以访问
    @PreAuthorize(value = "hasAnyAuthority('del','edit')")
    @GetMapping(value = "/test7")
    public Result test7(){
        return Result.success("test7");
    }

    //测试方法8 del和edit权限可以访问
    @PreAuthorize(value = "hasAuthority('del') and hasAuthority('edit')")
    @GetMapping(value = "/test8")
    public Result test8(){
        return Result.success("test8");
    }
}
6.1.4 第四步

如果登录成功,使用当前用户进行访问;如果登录失败,发现权限不够,报权限错误,定义处理器进行处理

package com.learn.frameworklearn.security;

/**
 * 权限不足处理器
 * 用户登录成功,访问某一个资源时因为权限不足,报异常
 */
@Component
public class LoginUnAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        response.setCharacterEncoding("utf-8");
        response.setContentType("application/json");
        Result result = Result.error(305,"权限不足,请重新授权。");
        //将消息json化
        String json = JSONUtil.toJsonStr(result);
        //送到客户端
        response.getWriter().print(json);
    }
}
6.1.5 第五步

自定义权限不足处理器后,需要进行注册,注册到SecurityConfig配置文件中

package com.learn.frameworklearn.security;

@Configuration
@EnableMethodSecurity(securedEnabled = true) //开发方法权限验证
public class SecurityConfig {

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Resource
    private LogoutStatusSuccessHandler logoutStatusSuccessHandler;

    @Resource
    private LoginUnAccessDeniedHandler loginUnAccessDeniedHandler;


    /**
     * SpringSecurity过滤器
     */
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() //防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) //取消session
                .and()
                .authorizeRequests()
                .antMatchers("/login","/test1").permitAll() //这个/login地址,登陆和未登录的人都可以访问访问
                .anyRequest().authenticated(); //除了上面设置的地址可以匿名访问,其它所有的请求地址需要认证访问

        //将自定义的过滤器注册到SpringSecurity过滤器链中,并且设置到UsernamePasswordAuthenticationFilter前面
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

        //自定义未登录处理(注册匿名请求访问私有化请求时的处理器)
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);

        //注册自定义的处理器(认证后的用户访问需要认证资源时因为权限不足走的处理器)  添加这里
        http.exceptionHandling().accessDeniedHandler(loginUnAccessDeniedHandler);

        //注册自定义处理器(注销处理器,注销成功后删除redis中的数据)
        http.logout().logoutSuccessHandler(logoutStatusSuccessHandler);

        return http.build();
    }


}

http://localhost:1002/FrameworkLearn/login 先登录,拿到token

在这里插入图片描述

携带token,访问http://localhost:1002/FrameworkLearn/test2

在这里插入图片描述

携带token,访问http://localhost:1002/FrameworkLearn/test3

在这里插入图片描述

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐