Spring Security 入门

Spring Security

废话不多说直接入门

1. 快速入门

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

1.1 准备工作

创建springboot项目并,添加依赖。

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- SpringMVC -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!--Spring Security-->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!-- Mysql驱动 -->
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <scope>runtime</scope>
</dependency>
<!-- redis -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- MyBatisPlus -->
<dependency>
    <groupId>com.baomidou</groupId>
    <artifactId>mybatis-plus-boot-starter</artifactId>
    <version>3.5.3</version>
</dependency>
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.26</version>
</dependency>
<!-- lombok -->
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
    <optional>true</optional>
</dependency>

application.yaml 配置文件

server:
  port: 8080
#日志格式
logging:
  level:
    com.hu.springbootsecuritty.mapper: debug
# 数据源
spring:
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql:///spring_security?serverTimezone=UTC
    username: root
    password: root
  redis:
    port: 6379
    host: 127.0.0.1
    database: 0

编写UserController

@RestController
public class UserController {
    /* 匿名访问 */
    @RequestMapping("/select")
    public R select(){
        return R.ok("【查询】了一条记录");
    }
}

1.2 启动项目

在浏览器访问 http://localhost:8080/select

因为SpringSecurity包含了很多过滤器,认证过滤器发现你没有登录就访问资源,会跳转到内置的登录页面

账号:user
密码:在控制台中查看

在这里插入图片描述

登录后成功后就会跳转到/select接口并返回结果

1.3 修改内置的账号密码

方式一: 配置文件方式
spring:
  security:
    user:
      name: admin
      password: 123
      
方式二:Api方式 写一个配置类继承WebSecurityConfigurerAdapter并重写configure方法,详细的自行百度(这不重要)

重启项目,还是访问 http://localhost:8080/select 接口,这时候控制台就不打印随机密码了,因为我们自定义了账号密码 。使用自定义的账号密码登录后可以正常访问接口。

在这里插入图片描述

1.4 涉及相关接口

InMemoryUserDetailsManager: 内置账户信息管理类,内置的账号密码和后面修改的都放在这里,是UserDetailsService的子类
UserDetailsService: SpringSecurity用户信息管理类的核心接口,管理用户信息来源(数据库还是内存以及其他…)
UserDetails: SpringSecurity封装用户信息的核心接口,给SpringSecurity送用户信息时SpringSecurity只认UserDetails以及子类

1.5 注销

SpringSecurity不只提供了内置登录逻辑,还提供了注销

直接访问 http://localhost:8080/logout 即可

2. 替换框架内置的用户名密码

在实际开发中,我们不可能使用内置的账号密码来进行认证操作,而是采用从数据库中读取用户信息来进行认证。

2.1 认证流程

在这里插入图片描述

2.2 导入数据库

这里是一个简单的RBAC数据表

用户----角色-----权限

SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for permissions
-- ----------------------------
DROP TABLE IF EXISTS `permissions`;
CREATE TABLE `permissions`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `permission_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 5 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of permissions
-- ----------------------------
INSERT INTO `permissions` VALUES (1, 'insert');
INSERT INTO `permissions` VALUES (2, 'delete');
INSERT INTO `permissions` VALUES (3, 'update');
INSERT INTO `permissions` VALUES (4, 'select');

-- ----------------------------
-- Table structure for role_permissions
-- ----------------------------
DROP TABLE IF EXISTS `role_permissions`;
CREATE TABLE `role_permissions`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `role_id` int NOT NULL,
  `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `permission_id` int NOT NULL,
  `permission_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `role_id`(`role_id` ASC) USING BTREE,
  INDEX `permission_id`(`permission_id` ASC) USING BTREE,
  CONSTRAINT `role_permissions_ibfk_1` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `role_permissions_ibfk_2` FOREIGN KEY (`permission_id`) REFERENCES `permissions` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 6 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of role_permissions
-- ----------------------------
INSERT INTO `role_permissions` VALUES (1, 1, 'admin', 1, 'insert');
INSERT INTO `role_permissions` VALUES (2, 1, 'admin', 2, 'delete');
INSERT INTO `role_permissions` VALUES (3, 1, 'admin', 3, 'update');
INSERT INTO `role_permissions` VALUES (4, 1, 'admin', 4, 'select');
INSERT INTO `role_permissions` VALUES (5, 2, 'user', 4, 'select');

-- ----------------------------
-- Table structure for roles
-- ----------------------------
DROP TABLE IF EXISTS `roles`;
CREATE TABLE `roles`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of roles
-- ----------------------------
INSERT INTO `roles` VALUES (1, 'admin');
INSERT INTO `roles` VALUES (2, 'user');

-- ----------------------------
-- Table structure for user_roles
-- ----------------------------
DROP TABLE IF EXISTS `user_roles`;
CREATE TABLE `user_roles`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `user_id` int NOT NULL,
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  `role_id` int NOT NULL,
  `role_name` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  INDEX `user_id`(`user_id` ASC) USING BTREE,
  INDEX `role_id`(`role_id` ASC) USING BTREE,
  CONSTRAINT `user_roles_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT,
  CONSTRAINT `user_roles_ibfk_2` FOREIGN KEY (`role_id`) REFERENCES `roles` (`id`) ON DELETE RESTRICT ON UPDATE RESTRICT
) ENGINE = InnoDB AUTO_INCREMENT = 4 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of user_roles
-- ----------------------------
INSERT INTO `user_roles` VALUES (1, 1, 'admin', 1, 'admin');
INSERT INTO `user_roles` VALUES (2, 2, 'user', 2, 'user');
INSERT INTO `user_roles` VALUES (3, 1, 'admin', 2, 'user');

-- ----------------------------
-- Table structure for users
-- ----------------------------
DROP TABLE IF EXISTS `users`;
CREATE TABLE `users`  (
  `id` int NOT NULL AUTO_INCREMENT,
  `username` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  `password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL,
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of users
-- ----------------------------
INSERT INTO `users` VALUES (1, 'admin', '$2a$10$jJwXNmhudzg1L50VhhtTy.ryCoAV/CamjgAPyinMb4ebuhEikXkwe');
INSERT INTO `users` VALUES (2, 'user', '$2a$10$h3Ir4Hwv.QTD8Ov10owvQuAsZZr/t5sYW1wFnnq7ZX8SBd7AeRfRK');

SET FOREIGN_KEY_CHECKS = 1;

2.3 代码

1.在项目中创建对应的数据库表的实体类 (略)

2.使用mybatisplus写一个查询用户信息的接口

public interface UserMapper extends BaseMapper<User> {}

3.实现UserDetails接口,封装用户信息(给SpringSecurity送数据),因为SpringSecrity只认该接口的实现类传过来的数据

@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUserDetails implements UserDetails {

    private User user;

    /**
     * 获取授权信息
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        //暂时只实现认证,不实现授权,所以这边权限给空集合
        return new ArrayList<>();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    // 下面方法,如在数据库中涉及相应字段,就可以根据情况设置,否则全部未true Security会自动获取里面的返回值

    /**
     * 是否过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否未锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 凭据是否过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否有效
     * @return
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

4.替换 InMemoryUserDetailsManager 实现 UserDetailsService方法

在入门案例中 使用的就是InMemoryUserDetailsManager,它里面获取的是存放在内存中的用户数据(框架内置或者我们在yml配置文件中自定义的)

现在我们要实现从数据库中那用户数据,就得实现 UserDetailsService 方法,重写loadUserByUsername方法覆盖原先的 InMemoryUserDetailsManager

@Service
public class UserDetailServiceImpl implements UserDetailsService {
    @Resource
    private UserMapper userMapper;

    @Override
    public LoginUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询该用户
        User user = userMapper.selectOne(new QueryWrapper<User>().eq("username", username));
        if (Objects.isNull(user)){
            throw new UsernameNotFoundException("用户不存在");
        }
        return new LoginUserDetails(user);
    }
}

5.在数据库中添加一条用户数据,用于登录操作,如果数据库中的密码是加密形式的,可以使用 BCryptPasswordEncoder 进行加密和解密

创建配置类 SecurityConfig

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

@Configuration
public class SecurityConfig{
    //密码编码器,
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }
}

6.如果数据库中的密码不是密文形式的,可以编写一个测试方法,使用PasswordEncoder将明文转换为密文形式

public static void main(String[] args) {
    PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
    String admin = passwordEncoder.encode("admin");
    System.out.println(admin);
}

重启项目,访问http://localhost:8080/select接口,使用自己数据库中的用户信息登录,ok!

3 替换登录页为前后端分离模式

上面我们使用SpringSecurity框架实现了简单的登录认证,并使用数据库中查到的用户信息来进行认证。

目前开发的主流方式就是前后端分离,后端项目中是没有前端页面的,那这样使用框架自带的登录页面就不太合适了,我们接下来就是要替换掉他

注意,本文主要是已后端为主,前端页面就不提供了。这就导致,我们接下来无法使用浏览器来测试,推荐使用postman、apipost、apifox等测试工具。

流程:

在这里插入图片描述

相关介绍

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

UsernamePasswordAuthenticationToken: 封装前端页面传递过来的用户名和密码,封装好后通过AuthenticationManager传递给SpringSecurity上下文

AuthenticationManager: SpringSecurity的认证管理器,见名知意,用来进行认证

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

3.1 准备工作

在前后端分离的设计者,我们需要针对项目做一些约束,如果每次返回的数据格式不一样,这会增加前端处理后端返回的数据的复杂度

设计前后端分离架构的统一返回值(无论是成功还是失败,后端给前端返回的数据结构是相同的)

R.java

@Data
public class R<T> {

    public static final Integer SUCCESS = 200;
    public static final Integer FAIL = 0;

    private Integer code;
    private String msg;
    private T data;

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

    // 成功的响应
    public static <T> R<T> ok(Integer code, String msg, T data) {
        return new R<>(code, msg, data);
    }

    public static <T> R<T> ok(T data) {
        return ok(SUCCESS, "请求成功", data);
    }

    public static <T> R<T> ok() {
        return ok(null);
    }
    public static <T> R<T> ok(String msg){
        return new R(SUCCESS,msg,null);
    }

    // 失败的响应
    public static <T> R<T> fail(Integer code, String msg, T data) {
        return new R<>(code, msg, data);
    }

    public static <T> R<T> fail(T data) {
        return fail(FAIL, "请求失败", data);
    }

    public static <T> R<T> fail() {
        return fail(null);
    }
}

3.2 替换内置的登录认证逻辑

1.配置认证管理器实例 AuthenticationManager 用来认证的

在SecurityConfig文件中添加即可

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

2.配置SecurityFilterChain

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

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.csrf().disable() // 防止跨站请求伪造
            .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 取消session
            .and()
            .authorizeRequests().antMatchers("/login").permitAll() // 所有人都可以访问登录接口
            .anyRequest().authenticated(); //除上面设置的接口 其他接口需要认证 
    return http.build();
}

3.代码实现

SpringSecurity 的认证逻辑
默认情况SpringSecurity内置了登录页面,内置了从页面获取数据,并将其数据送到SpringSecurity上下文的方式
当前前后端分离的逻辑,数据不再从页面获取,所以不能再使用内置逻辑,需要程序员自己实现将数据送到SpringSecurity上下文中

创建LoginController,写我们自己的登录逻辑

@RestController
@Slf4j
public class LoginController {
    @Resource
    private AuthenticationManager authenticationManager;
    /**
     * 由于不使用默认的登录逻辑和页面了,所以这里自己写一个登录接口(根据官方步骤来)
     * @param username
     * @param password
     * @return
     */
    @PostMapping("/login")
    public R login(String username , String password, HttpServletRequest request){
        log.info("登录接口——————————");
        // 封装用户名和密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        // 通过认证管理器调用认证方法  Authentication封装了用户的全部信息(认证信息和授权信息)
        try {
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            if (Objects.isNull(authenticate)){
                return  R.fail("用户名或者密码错误");
            }
        } catch (AuthenticationException e) {
            e.printStackTrace();
            return  R.fail("用户名或者密码错误");
        }
        return R.ok();
    }
}

4.测试代码,成功

由于,我们修改了登录逻辑,覆盖了原来的,现在没有登录页面了。这里用apipost测试

在这里插入图片描述

这里输入错误的密码,登录失败,测试成功

在这里插入图片描述

5.发现问题

刚刚访问**/login明明登录成功了,但是还是访问不了私有资源/select**,并且没有任何提示

在这里插入图片描述

3.3 问题分析

在上面3.2中出现的问题,明明登录了,但是还是不能访问接口,并且没有任何提示信息或者报错。把他拆分出来如下

  • 登录了,还是不能访问私有资源(也就是我们之前写的/select接口)
  • 错误码403,怎么没有任何提示信息(让用户自己百度???我觉的也行哈哈哈哈)

问题1

登录了,还是不能访问私有资源(也就是我们之前写的/select接口)

那是因为前后端分离项目不在是Session-Cookie机制

  1. 用户首次访问 Web 应用时,服务器创建一个 Session,并为该 Session 分配一个唯一的标识符(Session ID)。
  2. 服务器将 Session ID 作为 Cookie 的值发送到用户的浏览器。
  3. 用户在后续的请求中,浏览器会自动将包含 Session ID 的 Cookie 发送回服务器。
  4. 服务器通过 Session ID 找到对应的 Session 对象,从而获取用户的状态信息。

因为http协议是无状态的,如果没有Session-Cookie机制,那么第一次登录请求和第二次访问接口是没有关系的,导致第二次访问接口时,系统根本不知道你登录了没(虽然咱确实登录了)

问题2

错误码403,怎么没有任何提示信息

这样的情况是因为我们替换了原本的登录逻辑,他已经不走内置的处理器了,我们要自己定义处理器来处理未登录状态下访问私有资源的方法

具体解决方案在下面3.4章节 👇👇👇

3.4 解决未登录访问私有资源不提示问题

步骤如下

  • 实现AuthenticationEntryPoint接口,实现自定义的处理器
  • 将自定义的处理器注册到Spring Security的核心Filter中

创建 LoginUnAuthenticationEntryPointHandler 处理器

/**
 * 匿名请求访问私有化资源时的处理器
 * 因为,我们自己覆盖了自带的登录逻辑,现在没有登录的请求,不报错不提醒
 *
 * 注意,这是我们自己写的逻辑,spring security不认识,需要手动添加到过滤器中
 * @author huqianlong
 * @version 1.0
 */
@Component
public class LoginUnAuthenticationEntryPointHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        R<String> fail = R.fail("用户未登录或登录已过期,重新登录");
        String jsonStr = JSONUtil.toJsonStr(fail);
        // 设置编码格式
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");
        response.getWriter().println(jsonStr);
    }
}

将处理器注册到配置类的核心过滤器中

@Configuration
public class SecurityConfig{
    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;;
    
    /**
     * 配置SpringSecurity过滤器,替换他默认的
     * @return
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // 防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 取消session
                .and()
                .authorizeRequests().antMatchers("/login").permitAll() // 所有人都可以访问登录接口
                .anyRequest().authenticated(); //除上面设置的接口 其他接口需要认证
        
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);

        return http.build();
    }
}

再次测试不登录情况访问接口,现在正常提示出没有登录的信息

在这里插入图片描述

问题解决!

3.5 解决登录了还是不能访问私有资源问题

既然在前后端分离架构没有Session-Cookie机制 ,那我们得找一个东西,让用户登录后,给用户发这个东西,然后自己也留一份,然后每次访问系统接口的时候都带着这个东西,这样系统一看到这个东西,就知道他登录过了,而且每个人的都不一样,还能区分到底是谁,找个啥嘞?小票?手牌?卡?token?

1.生成token

这里为了省事,就用hu-tool工具包了,导入依赖就行

官网:https://doc.hutool.cn/

2.修改登录逻辑

修改LoginController

@RestController
@Slf4j
public class LoginController {
    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    /**
     * 由于不使用默认的登录逻辑和页面了,所以这里自己写一个登录接口(根据官方步骤来)
     * @param username
     * @param password
     * @return
     */
    @PostMapping("/login")
    public R login(String username , String password, HttpServletRequest request){
        log.info("登录接口——————————");

        // 封装用户名和密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        // 通过认证管理器调用认证方法  Authentication封装了用户的全部信息(认证信息和授权信息)
        try {
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            if (Objects.isNull(authenticate)){
                return  R.fail("用户名或者密码错误");
            }

            // 登录成功生成token
            Map<String, Object> map = new HashMap<>();
            map.put("username",username);
            map.put("expire_time", System.currentTimeMillis() + 1000*60*5);
            String token = JWTUtil.createToken(map,username.getBytes());
            // 将用户信息存入redis中
            String key = "token:"+token;
            // (UserDetails) authenticate.getPrincipal()保存了用户的全部数据
            UserDetails userDetails = (UserDetails) authenticate.getPrincipal();
            String jsonUserDetails = JSONUtil.toJsonStr(userDetails);
            log.info("json格式用户信息{}",jsonUserDetails);
            stringRedisTemplate.opsForValue().set(key, jsonUserDetails,1, TimeUnit.DAYS);

            HashMap<Object, Object> result = new HashMap<>();
            result.put("token",token);
            return R.ok(result);
        } catch (AuthenticationException e) {
            e.printStackTrace();
            return  R.fail("用户名或者密码错误");
        }
    }
}

3.测试

在这里插入图片描述

在redis中也会存储一份,其value是用户的信息

在这里插入图片描述

在请求头中放入生成的token访问私有资源/selcet,还是不能访问

在这里插入图片描述

噢对了,虽然咱登录成功生成了token返回给用户,自己有留了一份,但是用户访问的时候还没有验证token,得验证token才能知道,哪一个用户已经登录了

4.验证token

SpringSecurity 本质上就是好多个过滤器,请求访问接口,会先进过Security的多个过滤器,比如第一个过滤器是UsernamePasswordAuthenticationFilter,他是用来校验账户密码的

在这里插入图片描述

我们可以在 UsernamePasswordAuthenticationFilter 之前在加一层过滤器,实现校验token,这样就知道发请求的用户是不是已经登录了

在这里插入图片描述

步骤如下

  • 自定义一个校验token的过滤器
  • 注册到配置文件的核心过滤器中

自定义一个过滤器,获取token并解析,将解析到的用户信息仍到下一个过滤器中

@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        // 获取token
        String token = request.getHeader("token");
        // 从redis中获取用户信息
        if (StringUtils.hasText(token)){
            String userJsonStr = stringRedisTemplate.opsForValue().get("token:" + token);
            if (StringUtils.hasText(userJsonStr)){
                // 将用户json串 反序列化为 对象
                LoginUserDetails loginUserDetails = JSONUtil.toBean(userJsonStr, LoginUserDetails.class);
                if (loginUserDetails != null){
                    // 封装用户信息,送到下一个过滤器:UsernamePasswordAuthenticationFilter
                    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUserDetails, null,loginUserDetails.getAuthorities());
                    // 将redis中的用户数据送到spring security 上下文中
                    SecurityContextHolder.getContext().setAuthentication(authenticationToken);
                }else {
                    SecurityContextHolder.getContext().setAuthentication(null);
                }
            }
        }
        // 放行,后面交给Spring Security 处理
        filterChain.doFilter(request,response);
    }
}

在配置文件中注册

@Configuration
public class SecurityConfig{

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;;
 
    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    //密码编码器,
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

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

    /**
     * 配置SpringSecurity过滤器,替换他默认的
     * @return
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // 防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 取消session
                .and()
                .authorizeRequests().antMatchers("/login").permitAll() // 所有人都可以访问登录接口
                .anyRequest().authenticated(); //除上面设置的接口 其他接口需要认证

        // 每次请求都要先判断token,不然框架不知道你登录了
        // 注册自定义的过滤器到spring security过滤器中,并设置过滤器在UsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 注册匿名访问私有资源的(没登陆还向访问需要登录的接口)处理器
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
   
        return http.build();
    }
}

5.再次测试

先登录

在这里插入图片描述

将生成的token,放在请求头中,访问接口

在这里插入图片描述

测试成功了,闲着没事看了一下redis,我靠,怎么这么多。每次登录都创建了一个token,token过期时间还没有到

这个问题在下面3.6解决

在这里插入图片描述

3.6 解决每次登录都创建一个新token问题

思路:在前后端分离架构中,用户登录成功后的每次请求都会带着后端给他返回的token。那我们可以在登录成功并创token之前,先一步获取到用户携带的token,解析token(生成token是将用户名放了进去,这里可以解析出来)看用户名是否匹配,如果匹配的话,那就说明他之前登录过,那我们就直接把reids中的token 给他删了,在登录接口走完后,会再次给他生成一个新的token。

其实,上面的思路,也可以叫做令牌刷新,但是还不完整,这先不对他进行深究

修改LoginController

@RestController
@Slf4j
public class LoginController {
    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @PostMapping("/login")
    public R login(String username , String password, HttpServletRequest request){
        log.info("登录接口——————————");

        // 令牌刷新,解析token,发现redis中有,就删掉
        String token_ = request.getHeader("token");
        if (StringUtils.hasText(token_)){
            String username_ = (String) JWTUtil.parseToken(token_).getPayload().getClaim("username");
            if (username_.equals(username)){
                stringRedisTemplate.delete("token:"+token_);
            }
        }

        // 封装用户名和密码
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);

        // 通过认证管理器调用认证方法  Authentication封装了用户的全部信息(认证信息和授权信息)
        try {
            Authentication authenticate = authenticationManager.authenticate(authenticationToken);
            if (Objects.isNull(authenticate)){
                return  R.fail("用户名或者密码错误");
            }

            // 登录成功生成token
            Map<String, Object> map = new HashMap<>();
            map.put("username",username);
            map.put("expire_time", System.currentTimeMillis() + 1000*60*5);
            String token = JWTUtil.createToken(map,username.getBytes());
            // 将用户信息存入redis中
            String key = "token:"+token;
            // (UserDetails) authenticate.getPrincipal()保存了用户的全部数据
            UserDetails userDetails = (UserDetails) authenticate.getPrincipal();
            String jsonUserDetails = JSONUtil.toJsonStr(userDetails);
            log.info("json格式用户信息{}",jsonUserDetails);
            stringRedisTemplate.opsForValue().set(key, jsonUserDetails,1, TimeUnit.DAYS);

            HashMap<Object, Object> result = new HashMap<>();
            result.put("token",token);
            return R.ok(result);
        } catch (AuthenticationException e) {
            e.printStackTrace();
            return  R.fail("用户名或者密码错误");
        }
    }
}

测试一下,登录后,携带token再次登录,redis中只保持有一个token

到这里,整个登录的流程就完成了

3.7 处理注销之后的操作

框架内置了注销的接口,我们不用自己再编写了,但是注销后还有一件事情要做,那就是清除reid中的数据。

  • 创建注销成功处理器
  • 在配置文件中注册到核心过滤器中

LogoutStatusSuccessHandler

/**
 * 框架自带了logout退出,退出后跳转用户登录页,现在前后分离
 * 所以自定义退出后的操作
 *
 * @author huqianlong
 * @version 1.0
 */
@Component
public class LogoutStatusSuccessHandler implements LogoutSuccessHandler {

    @Resource
    private StringRedisTemplate stringRedisTemplate;

    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        // 获取token
        String token = request.getHeader("token");
        if (StringUtils.hasText(token)){
            // 从redis中删除token
            stringRedisTemplate.delete("token:" + token);
        }

        R<String> fail = R.ok("退出成功");
        String jsonStr = JSONUtil.toJsonStr(fail);
        // 设置编码格式
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        response.getWriter().println(jsonStr);
    }
}

SecurityConfig

@Configuration
public class SecurityConfig{

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;;

    @Resource
    private LogoutStatusSuccessHandler logoutStatusSuccessHandler;

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
   

    //密码编码器,
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

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

    /**
     * 配置SpringSecurity过滤器,替换他默认的
     * @return
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // 防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 取消session
                .and()
                .authorizeRequests().antMatchers("/login").permitAll() // 所有人都可以访问登录接口
                .anyRequest().authenticated(); //除上面设置的接口 其他接口需要认证

        // 每次请求都要先判断token,不然框架不知道你登录了
        // 注册自定义的过滤器到spring security过滤器中,并设置过滤器在UsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 注册匿名访问私有资源的(没登陆还向访问需要登录的接口)处理器
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
        
        // 注册自定义 退出成功的处理器
        http.logout().logoutSuccessHandler(logoutStatusSuccessHandler);
        return http.build();
    }

    public static void main(String[] args) {
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        String admin = passwordEncoder.encode("admin");
        System.out.println(
            admin
        );

        String user = passwordEncoder.encode("user");
        System.out.println("user=" + user);
    }

}

测试

在这里插入图片描述

4.授权

Spring Security is a framework that provides authentication, authorization, and protection against common attacks.

SpringSecurity除了提供认证,还提供了授权的功能。认证相当于登录了,而授权,就是用户是什么角色,能做些什么事,或者哪些接口是都可以访问,哪些接口是指定角色或权限的用户可以访问。

那这里就不得不说一下RBAC了

4.1 RBAC

RBAC(Role-Based Access Control)即基于角色的访问控制。

在 RBAC 中,权限与角色相关联,用户通过成为适当角色的成员而得到这些角色的权限。这样可以极大地简化权限管理。

例如,一个公司的系统中,可以设置 “管理员”“普通员工”“部门经理” 等角色。管理员角色可能拥有系统的最高权限,包括数据的修改、删除、系统设置等;普通员工角色可能只拥有对特定数据的查看和有限的操作权限;部门经理角色则介于两者之间,拥有对本部门数据的更多管理权限。

他在数据库中的直接体现就是

用户----角色----权限

用户和角色表之前会产生用户角色关联表,角色和权限之间会产生角色权限关联表。这样就形成了完整的基于RBAC的数据库表结构

在这里插入图片描述

上面表结构就是在上面 2.2导入数据库中导入的sql,是一个简单的rbac模型。为了方便后期查询,这里适当的添加了一些冗余字段。

4.2 将权限数据从数据库中推送到上下文

在第二章中,我们从数据库中获取数据是通过UserDetailsService送到SpringSecurity上下文中的。那么用户的权限数据也是在这里。

实现步骤如下:

  • 查询权限数据
  • 封装到UserDetails对象

1.查询数据

这里使用了Mybatis Plus

UserRoleMapper

@Mapper
public interface UserRoleMapper extends BaseMapper<UserRole> {}

RolePermissionMapper.java

@Mapper
public interface RolePermissionMapper extends BaseMapper<RolePermission> {
    /**
     * 通过角色名称批量查询角色权限列表
     * @param roleNameList
     * @return
     */
    List<RolePermission> selectBatchByRoleName(@Param("roleNameList") List<String> roleNameList);
}

RolePermissionMapper.xml

注意,该xml文件创建在项目resources文件夹下同RolePermissionMapper.java同目录

<?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.hu.springbootsecuritty.mapper.RolePermissionMapper">

    <select id="selectBatchByRoleName" resultType="com.hu.springbootsecuritty.pojo.RolePermission">
        select *
        from role_permissions
        where role_name in
            <foreach collection="roleNameList" item="roleName" open="(" separator="," close=")">
                #{roleName}
            </foreach>
    </select>
</mapper>

2.修改UserDetails

/**
 * 封装用户信息
 *
 * @author huqianlong
 * @version 1.0
 */
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUserDetails implements UserDetails {

    private User user;

    // 角色名称列表
    private List<String> roleNames;

    // 权限名称列表
    private List<String> permissionNames;

    /**
     * 获取授权信息
     * @return
     */
    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        ArrayList<GrantedAuthority> grantedAuthorities = new ArrayList<>();
        // 处理角色信息
        if (!CollectionUtils.isEmpty(roleNames)){
            for (String roleName : roleNames) {
                /**
                 * 为了区分角色和权限,在角色名前面加个前缀
                 */
                roleName = "ROLE_" + roleName;
                grantedAuthorities.add(new SimpleGrantedAuthority(roleName));
            }
        }
        // 处理权限信息
        if (!CollectionUtils.isEmpty(permissionNames)){
            for (String permissionName : permissionNames) {
                grantedAuthorities.add(new SimpleGrantedAuthority(permissionName));
            }
        }
        return grantedAuthorities;
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }

    @Override
    public String getUsername() {
        return user.getUsername();
    }

    // 下面方法,如在数据库中涉及相应字段,就可以根据情况设置,否则全部未true Security会自动获取里面的返回值

    /**
     * 是否过期
     * @return
     */
    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    /**
     * 账户是否未锁定
     * @return
     */
    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    /**
     * 凭据是否过期
     * @return
     */
    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    /**
     * 是否有效
     * @return
     */
    @Override
    public boolean isEnabled() {
        return true;
    }
}

3.修改UserDetailsServiceImpl

/**
 * 从数据库中获取用户信息,将用户信息送到SpringSecurity上下文中
 *
 * @author huqianlong
 * @version 1.0
 */
@Service
public class UserDetailServiceImpl implements UserDetailsService {
    @Resource
    private UserMapper userMapper;
    @Resource
    private UserRoleMapper userRoleMapper;
    @Resource
    private RolePermissionMapper rolePermissionMapper;

    @Override
    public LoginUserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        // 查询该用户
        User user = userMapper.selectOne(new QueryWrapper<User>().eq("username", username));
        if (Objects.isNull(user)){
            throw new UsernameNotFoundException("用户不存在");
        }

        // 当前用户角色名称列表
        List<String> roleNameList = new ArrayList<>();
        // 当前用户的权限名列表
        List<String> permissionNameList = new ArrayList<>();

        // 根据该用户id查询角色列表
        List<UserRole> userRoleList = userRoleMapper.selectList(new QueryWrapper<UserRole>().eq("user_id", user.getId()));
        if (!CollectionUtils.isEmpty(userRoleList)){
            // 获取到所有的角色名称添加到集合中
            List<String> userRoleNameList = userRoleList.stream().map(UserRole::getRoleName).collect(Collectors.toList());
            roleNameList.addAll(userRoleNameList);

            // 根据查询到的该用户的角色名称,查询出角色所拥有的权限
            List<RolePermission> rolePermissionList = rolePermissionMapper.selectBatchByRoleName(roleNameList);
            if (!CollectionUtils.isEmpty(rolePermissionList)){
                List<String> userPermissionNameList = rolePermissionList.stream().map(RolePermission::getPermissionName).collect(Collectors.toList());
                // 获取到所有的权限名称添加到集合中
                permissionNameList.addAll(userPermissionNameList);
            }
        }

        return new LoginUserDetails(user,roleNameList,permissionNameList);
    }
}

到这里,用户登录时,框架从数据库中封装UserDetails对象时就会连带用户权限数据一块封装

4.3 授权

基于配置类的授权方式,这个不太推荐,稍微演示一下

还是使用上面的/select接口,我们向让他不等了也可以访问

在这里插入图片描述

不登录直接访问接口

!!!重点推荐注解方式的授权

1.在配置类添加注解

在这里插入图片描述

2.添加测试接口

@RestController
public class UserController {
    /* 匿名访问 */
    @RequestMapping("/select")
    public R select(){
        return R.ok("【查询】了一条记录");
    }

    /* 具有任意一个就行 */
    @PreAuthorize(value = "hasAnyRole('user','admin')")
    @RequestMapping("/delete")
    public R delete(){
        return R.ok("【删除】了一条记录");
    }
    /* 同时具有两种权限 */
    @PreAuthorize(value = "hasRole('admin') and hasRole('user')")
    @RequestMapping("/test")
    public R test(){
        return R.ok("【插入】了一条记录");
    }


    @PreAuthorize(value = "hasRole('user')")
    @RequestMapping("/update")
    public R update(){
        return R.ok("【修改】了一条记录");
    }

    @PreAuthorize(value = "hasRole('admin')")
    @RequestMapping("/insert")
    public R insert(){
        return R.ok("【插入】了一条记录");
    }

    /* 指定权限访问 */
    @PreAuthorize(value = "hasAuthority('select')")
    @RequestMapping("/test1")
    public R test1(){
        return R.ok("【插入】了一条记录");
    }

}

3.注解解释

@PermitAll :表示允许所有用户访问被注解的方法或类,无论其角色如何。

@PreAuthorize(value = "hasRole('admin')")
	hasRole('admin'):允许拥有admin角色的用户访问
	
@PreAuthorize(value = "hasAnyRole('user','admin')")
	hasAnyRole('user','admin'):允许用户拥有括号里任意一个角色访问
	
@PreAuthorize(value = "hasRole('admin') and hasRole('user')")	
	hasRole('admin') and hasRole('user'):用户同时拥有admin和user角色才可以访问
    
@PreAuthorize(value = "hasAuthority('select')")
	hasAuthority('select'):用户拥有select权限才可以访问

4.修改数据库中用户的角色权限,登录后访问接口即可,这里不在赘述。

@RequestMapping("/select")
public R select(){
    return R.ok("【查询】了一条记录");
}
注意该接口没有使用注解限制,但是还是可以匿名访问。

这是因为上面在配置类中配置了`/selectd`为所有用户都可以访问。

这就说明注解和配置类的配置是同时生效的,但是注解的优先级更高,具体如下

这里两边设置的不一样

在这里插入图片描述

结果却显示没有登录

在这里插入图片描述

这里其实还有一个问题,在测试授权的时候会发现,登录成功的前提下,用户访问不满足权限的接口时,是没有返回信息的,如下

在这里插入图片描述

在这里插入图片描述

4.4 解决没权限访问接口不报错问题

  • 添加对应处理器
  • 在核心过滤器中配置该处理器

LoginUnAccessDeniedHandler

/**
 * 权限不足处理器
 * 用户登录成功了,访问接口,但是权限不足
 *
 * @author huqianlong
 * @version 1.0
 */
@Component
public class LoginUnAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        R<String> fail = R.fail("权限不足,请联系管理员。");
        String jsonStr = JSONUtil.toJsonStr(fail);
        // 设置编码格式
        response.setContentType("application/json");
        response.setCharacterEncoding("utf-8");

        response.getWriter().println(jsonStr);
    }
}

修改配置类

/**
 * TODO
 *
 * @author huqianlong
 * @version 1.0
 */
@Configuration
@EnableMethodSecurity // 开启注解形式的授权
public class SecurityConfig{

    @Resource
    private LoginUnAuthenticationEntryPointHandler loginUnAuthenticationEntryPointHandler;;

    @Resource
    private LogoutStatusSuccessHandler logoutStatusSuccessHandler;

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Resource
    private LoginUnAccessDeniedHandler loginUnAccessDeniedHandler;

    //密码编码器,
    @Bean
    public PasswordEncoder passwordEncoder(){
        return new BCryptPasswordEncoder();
    }

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

    /**
     * 配置SpringSecurity过滤器,替换他默认的
     * @return
     */
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.csrf().disable() // 防止跨站请求伪造
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 取消session
                .and()
                .authorizeRequests().antMatchers("/login","/select").permitAll() // 所有人都可以访问登录接口
                .anyRequest().authenticated(); //除上面设置的接口 其他接口需要认证

        // 每次请求都要先判断token,不然框架不知道你登录了
        // 注册自定义的过滤器到spring security过滤器中,并设置过滤器在UsernamePasswordAuthenticationFilter之前
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 注册匿名访问私有资源的(没登陆还向访问需要登录的接口)处理器
        http.exceptionHandling().authenticationEntryPoint(loginUnAuthenticationEntryPointHandler);
        // 注册权限不足处理器
        http.exceptionHandling().accessDeniedHandler(loginUnAccessDeniedHandler);
        // 注册自定义 退出成功的处理器
        http.logout().logoutSuccessHandler(logoutStatusSuccessHandler);
        return http.build();
    }

    public static void main(String[] args) {
        PasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

        String admin = passwordEncoder.encode("admin");
        System.out.println(
            admin
        );

        String user = passwordEncoder.encode("user");
        System.out.println("user=" + user);
    }

}

重启项目后,再次测试

在这里插入图片描述

5 结束

以上就是一个简单的实现SpringSecurity的认证授权功能,完结。

参考:https://hs-an-yue.github.io/2024/09/20/SpringSecurity%E5%BF%AB%E9%80%9F%E5%85%A5%E9%97%A8/

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值