SpringSecurity

2022-12-05 15:21:49 浏览数 (1)

0.简介

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

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

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

认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户授权:经过认证后判断当前用户是否有权限进行某个操作

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

1.入门Demo

1.1新建项目

创建项目不用多说,创建maven或者spring项目都行。端口默认8080就行,配置文件先不用问,先来个小Demo,没什么好说的。

我这里项目名称叫SecurityDemo1

① 设置父工程 添加依赖

代码语言:javascript复制
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.12</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
</dependencies>

② 启动类

代码语言:javascript复制
@SpringBootApplication
public class SecurityDemo1Application {
    public static void main(String[] args) {
        SpringApplication.run(SecurityDemo1Application.class, args);
    }
}

③ 创建Controller

写一个测试接口(/hello),用RestController返回一个字符串就行。

代码语言:javascript复制
@RestController
public class HelloController {

    @RequestMapping("/hello")
    public String hello(){
        return "Hello World~";
    }
    
}

访问http://localhost:8080/hello,接口运行正常:

1.2 引入SpringSecurity

注意spring版本和security版本的兼容性问题就行了,最好是按照我给的版本进行测试。

目前推荐security版本最好是2.5.14

代码语言:javascript复制
<dependency>
     <groupId>org.springframework.boot</groupId>
     <artifactId>spring-boot-starter-security</artifactId>
 </dependency>

​ 引入依赖后我们在尝试去访问之前的接口就会自动跳转到一个SpringSecurity的默认登陆页面。

默认用户名是user,密码会输出在控制台。必须登陆之后才能对接口进行访问。

2. 认证

2.1 登陆校验流程

SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。

这里我们可以看看入门Demo中的过滤器。

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

UsernamePasswordAuthenticationFilter:负责处理登陆页面填写用户名密码后的登陆请求。入门Demo的认证工作主要由它负责。 ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException。 FilterSecurityInterceptor:负责权限校验的过滤器。

2.2认证流程

Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。 AuthenticationManager接口:定义了认证Authentication的方法 UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。 UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

2.3项目演示

2.3.1构建项目

更多详情前往github查看项目SecurityDemo3:

用到的数据库实体类sys_user即可,操作不是太多。

代码语言:javascript复制
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;

-- ----------------------------
-- Table structure for sys_user
-- ----------------------------
DROP TABLE IF EXISTS `sys_user`;
CREATE TABLE `sys_user`  (
  `id` bigint(0) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `user_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '用户名',
  `nick_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '昵称',
  `password` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NULL' COMMENT '密码',
  `type` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '用户类型:0代表普通用户,1代表管理员',
  `status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
  `email` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '邮箱',
  `phone_number` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '手机号',
  `sex` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
  `avatar` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '头像',
  `create_by` bigint(0) NULL DEFAULT NULL COMMENT '创建人的用户id',
  `create_time` datetime(0) NULL DEFAULT NULL COMMENT '创建时间',
  `update_by` bigint(0) NULL DEFAULT NULL COMMENT '更新人',
  `update_time` datetime(0) NULL DEFAULT NULL COMMENT '更新时间',
  `del_flag` int(0) NULL DEFAULT 0 COMMENT '删除标志(0代表未删除,1代表已删除)',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 22 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '用户表' ROW_FORMAT = Dynamic;

-- ----------------------------
-- Records of sys_user
-- ----------------------------
INSERT INTO `sys_user` VALUES (1, 'roydon', 'roydon233', '$2a$10$.C5nLKwbb4VW3qSuqsaykuAj9mKa4XQaSfL.dOmmOr4L2fERgLgtG', '1', '0', '3133010060@qq.com', '18888889999', '1', 'http://rjh778l49.bkt.clouddn.com/2022/10/09/61d283c195064c2dbf9e02e9a609700a.jpg', NULL, '2022-01-05 09:01:56', 1, '2022-01-30 15:37:03', 0);
INSERT INTO `sys_user` VALUES (18, 'weixin', 'weixin', '$2a$10$y3k3fnMZsBNihsVLXWfI8uMNueVXBI08k.LzWYaKsW8CW7xXy18wC', '0', '0', 'weixin@qq.com', NULL, NULL, 'https://img1.imgtp.com/2022/09/01/w4nMeVBG.jpg', -1, '2022-01-30 17:18:44', -1, '2022-01-30 17:18:44', 0);

SET FOREIGN_KEY_CHECKS = 1;

引入必要的的依赖

代码语言:javascript复制
<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.6.12</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </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>
    <!--security-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
        <version>2.7.4</version>
    </dependency>
    <!--redis序列化器-fastjson-->
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.33</version>
    </dependency>
    <!--jwt-->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt</artifactId>
        <version>0.9.0</version>
    </dependency>
    <!--mybatis-plus-->
    <dependency>
        <groupId>com.baomidou</groupId>
        <artifactId>mybatis-plus-boot-starter</artifactId>
        <version>3.5.2</version>
    </dependency>
</dependencies>

配置文件

代码语言:javascript复制
server:
  port: 8888 # 端口

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/{database}?characterEncoding=utf-8&serverTimezone=UTC
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver

mybatis-plus:
  mapper-locations: classpath*:/mapper/*.xml
  configuration:
    # 日志
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      logic-delete-field: delFlag
      logic-delete-value: 1
      logic-not-delete-value: 0
      id-type: auto

创建数据表对应User实体类

代码语言:javascript复制
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("sys_user")
public class User  {
    @TableId
    private Long id;
    //用户名
    private String userName;
    //昵称
    private String nickName;
    //密码
    private String password;
    //用户类型:0代表普通用户,1代表管理员
    private String type;
    //账号状态(0正常 1停用)
    private String status;
    //邮箱
    private String email;
    //手机号
    private String phoneNumber;
    //用户性别(0男,1女,2未知)
    private String sex;
    //头像
    private String avatar;
    //创建人的用户id
    private Long createBy;
    //创建时间
    private Date createTime;
    //更新人
    private Long updateBy;
    //更新时间
    private Date updateTime;
    //删除标志(0代表未删除,1代表已删除)
    private Integer delFlag;
}

接着是mapper接口

代码语言:javascript复制
public interface UserMapper extends BaseMapper<User> {}

接着是启动类,并加上mapper扫描

代码语言:javascript复制
@Slf4j
@SpringBootApplication
@MapperScan("com.roydon.securitydemo3.mapper")
public class SecurityDemo3Application {
    public static void main(String[] args) {
        SpringApplication.run(SecurityDemo3Application.class, args);
        log.info("项目启动中...");
    }
}

测试MP是否能正常使用

代码语言:javascript复制
@SpringBootTest
class SecurityDemo3ApplicationTests {
    
    @Resource
    private UserMapper userMapper;

    @Test
    public void testUserMapper(){System.out.println(userMapper.selectList(null));}
}

工具类和一些必要配置在提供的项目中以及给出,本文不再过多赘述。

2.3.2loadUser

创建一个类实现UserDetailsService接口,重写其中的loadUserByUsername方法。

这一步的目的在于根据登录用户名称查询出对应用户,并给此用户赋予相应权限(后续授权模块会完善,此处先TODO),之后封装成LoginUser,这个LoginUser实体类也是继承了security框架提供的UserDetails。

代码语言:javascript复制
@Slf4j
@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Resource
    private UserMapper userMapper;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
        queryWrapper.eq(StringUtils.isNotEmpty(username), User::getUserName, username);
        User user = userMapper.selectOne(queryWrapper);

        log.info("查询到数据库用户为:{}",user);

        if (Objects.isNull(user)) {
            throw new UsernameNotFoundException("用户名或密码错误");
        }
        // TODO 查询角色权限

        return new LoginUser(user);
    }
}

因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息和用户的权限(此处权限定义为null,后续授权模块会用到)封装在其中。

代码语言:javascript复制
@Data
@NoArgsConstructor
public class LoginUser implements UserDetails {

    private User user;

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

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        // 后续授权模块会用到。。。
        return null;
    }

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

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

    @Override
    public boolean isAccountNonExpired() {
        return true;
    }

    @Override
    public boolean isAccountNonLocked() {
        return true;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return true;
    }

    @Override
    public boolean isEnabled() {
        return true;
    }
}

2.3.3SecurityConfig

定义SpringSecurity的配置类,继承WebSecurityConfigurerAdapter。

实际项目中不会把密码明文存储在数据库中。一般使用SpringSecurity为我们提供的BCryptPasswordEncoder

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

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

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

代码语言:javascript复制
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问 anonymous
                .antMatchers("/user/login").anonymous()
                .anyRequest().authenticated();

    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

}

2.3.4登录接口

代码语言:javascript复制
public interface LoginService {

    ResponseResult login(User user);

}
代码语言:javascript复制
@RestController
public class LoginController {

    @Autowired
    private LoginServcie loginServcie;

    @PostMapping("/user/login")
    public ResponseResult login(@RequestBody User user){
        return loginServcie.login(user);
    }
}

LoginService实现类

代码语言:javascript复制
@Slf4j
@Service
public class LoginServiceImpl implements LoginService {

    @Resource
    private AuthenticationManager authenticationManager;

    @Resource
    private RedisCache redisCache;

    /**
     * 用户登录
     * 1.根据用户信息获取 Authentication
     * 2.根据用户 id 生成 jwt token
     * 3.存入 redis
     * 4.token 响应给前端
     * @param user 登录用户
     * @return ResponseResult(CODE_200, " 登陆成功 ", map)
     */
    @Override
    public ResponseResult login(User user) {

        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
        Authentication authenticate = authenticationManager.authenticate(authenticationToken);
        log.info("Authentication认证信息:{}", authenticate);

        if (Objects.isNull(authenticate)) {
            throw new RuntimeException("用户名或密码错误");
        }

        //使用userid生成token
        LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
        String userId = loginUser.getUser().getId().toString();
        String jwt = JwtUtil.createJWT(userId);

        //authenticate存入redis
        redisCache.setCacheObject(LOGIN_KEY   userId, loginUser);

        //把token响应给前端
        HashMap<String, String> map = new HashMap<>();
        map.put("token", jwt);

        return new ResponseResult(CODE_200, "登陆成功", map);
    }

}

测试接口

2.3.5认证过滤器

这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。使用userid去redis中获取对应的LoginUser对象。然后封装Authentication对象存入SecurityContextHolder。

代码语言:javascript复制
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {

    @Resource
    private RedisCache redisCache;

    @Override
    protected void doFilterInternal(HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain filterChain) throws ServletException, IOException {
        //获取token
        String token = request.getHeader("token");
        if (!StringUtils.hasText(token)) {
            //无token,放行
            filterChain.doFilter(request, response);
            return;
        }
        //解析token
        String userid;
        try {
            Claims claims = JwtUtil.parseJWT(token);
            userid = claims.getSubject();
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("token非法");
        }
        //从redis中获取用户信息
        String redisKey = LOGIN_KEY   userid;
        LoginUser loginUser = redisCache.getCacheObject(redisKey);
        if (Objects.isNull(loginUser)) {
            throw new RuntimeException("用户未登录");
        }
        //存入SecurityContextHolder
        //TODO 获取权限信息封装到 Authentication 中,此处存null
        UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

把token校验过滤器添加到过滤器链中

代码语言:javascript复制
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {

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

    @Resource
    private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
                .cors()
                .and()
                .csrf().disable()
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
                .and()
                .authorizeRequests()
                // 对于登录接口 允许匿名访问 anonymous
                .antMatchers("/user/login").anonymous()
                .anyRequest().authenticated();

        //把token校验过滤器添加到过滤器链中
        http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);

    }

    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception {
        return super.authenticationManagerBean();
    }

}

2.3.6退出登录

退出登陆接口只需要获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。

代码语言:javascript复制
public interface LoginService {

    ResponseResult login(User user);

    ResponseResult logout();

}
代码语言:javascript复制
@RestController
@RequestMapping("/user")
public class LoginController {

    @Resource
    private LoginService loginServcie;

    @PostMapping("/login")
    public ResponseResult login(@RequestBody User user){
        return loginServcie.login(user);
    }

    @RequestMapping("/logout")
    public ResponseResult logout(){
        return loginServcie.logout();
    }

}

在实现类中实现退出登录方法

代码语言:javascript复制
/**
 * 退出登录
 * 1.获取用户信息 SecurityContextHolder.getContext().getAuthentication();
 * 2.通过用户 id 清除 redis
 * @return ResponseResult(CODE_200, " 退出成功 ");
 */
@Override
public ResponseResult logout() {
    Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
    LoginUser loginUser = (LoginUser) authentication.getPrincipal();
    Long userid = loginUser.getUser().getId();
    redisCache.deleteObject(LOGIN_KEY   userid);
    return new ResponseResult(CODE_200, "退出成功");
}

测试退出登录接口,携带请求头token

测试文档在线地址:apifox

ps:测试文档只是提供参考,具体测试你得运行在本地。

3.授权

未完待续。。。

0 人点赞