0.简介
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户。
授权:经过认证后判断当前用户是否有权限进行某个操作。
而认证和授权也是SpringSecurity作为安全框架的核心功能。
1.入门Demo
1.1新建项目
创建项目不用多说,创建maven或者spring项目都行。端口默认8080就行,配置文件先不用问,先来个小Demo,没什么好说的。
我这里项目名称叫SecurityDemo1
① 设置父工程 添加依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.12</version> <relativePath/> </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>
|
② 启动类
1 2 3 4 5 6
| @SpringBootApplication public class SecurityDemo1Application { public static void main(String[] args) { SpringApplication.run(SecurityDemo1Application.class, args); } }
|
③ 创建Controller
写一个测试接口(/hello),用RestController返回一个字符串就行。
1 2 3 4 5 6 7 8 9
| @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。
1 2 3 4
| <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即可,操作不是太多。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| SET NAMES utf8mb4; SET FOREIGN_KEY_CHECKS = 0;
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;
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;
|
引入必要的的依赖
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.6.12</version> <relativePath/> </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> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> <version>2.7.4</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson</artifactId> <version>1.2.33</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt</artifactId> <version>0.9.0</version> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.2</version> </dependency> </dependencies>
|
配置文件
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| 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实体类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36
| @Data @AllArgsConstructor @NoArgsConstructor @TableName("sys_user") public class User { @TableId private Long id; private String userName; private String nickName; private String password; private String type; private String status; private String email; private String phoneNumber; private String sex; private String avatar; private Long createBy; private Date createTime; private Long updateBy; private Date updateTime; private Integer delFlag; }
|
接着是mapper接口
1
| public interface UserMapper extends BaseMapper<User> {}
|
接着是启动类,并加上mapper扫描
1 2 3 4 5 6 7 8 9
| @Slf4j @SpringBootApplication @MapperScan("com.roydon.securitydemo3.mapper") public class SecurityDemo3Application { public static void main(String[] args) { SpringApplication.run(SecurityDemo3Application.class, args); log.info("项目启动中..."); } }
|
测试MP是否能正常使用
1 2 3 4 5 6 7 8 9
| @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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| @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("用户名或密码错误"); }
return new LoginUser(user); } }
|
因为UserDetailsService方法的返回值是UserDetails类型,所以需要定义一个类,实现该接口,把用户信息和用户的权限(此处权限定义为null,后续授权模块会用到)封装在其中。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| @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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33
| @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() .antMatchers("/user/login").anonymous() .anyRequest().authenticated();
}
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
}
|
2.3.4登录接口
1 2 3 4 5
| public interface LoginService {
ResponseResult login(User user);
}
|
1 2 3 4 5 6 7 8 9 10 11
| @RestController public class LoginController {
@Autowired private LoginServcie loginServcie;
@PostMapping("/user/login") public ResponseResult login(@RequestBody User user){ return loginServcie.login(user); } }
|
LoginService实现类
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
| @Slf4j @Service public class LoginServiceImpl implements LoginService {
@Resource private AuthenticationManager authenticationManager;
@Resource private RedisCache redisCache;
@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("用户名或密码错误"); }
LoginUser loginUser = (LoginUser) authenticate.getPrincipal(); String userId = loginUser.getUser().getId().toString(); String jwt = JwtUtil.createJWT(userId);
redisCache.setCacheObject(LOGIN_KEY + userId, loginUser);
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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38
| @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 { String token = request.getHeader("token"); if (!StringUtils.hasText(token)) { filterChain.doFilter(request, response); return; } String userid; try { Claims claims = JwtUtil.parseJWT(token); userid = claims.getSubject(); } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("token非法"); } String redisKey = LOGIN_KEY + userid; LoginUser loginUser = redisCache.getCacheObject(redisKey); if (Objects.isNull(loginUser)) { throw new RuntimeException("用户未登录"); } UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, null); SecurityContextHolder.getContext().setAuthentication(authenticationToken); filterChain.doFilter(request, response); } }
|
把token校验过滤器添加到过滤器链中
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34
| @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() .antMatchers("/user/login").anonymous() .anyRequest().authenticated();
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { return super.authenticationManagerBean(); }
}
|
2.3.6退出登录
退出登陆接口只需要获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。
1 2 3 4 5 6 7
| public interface LoginService {
ResponseResult login(User user);
ResponseResult logout();
}
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| @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(); }
}
|
在实现类中实现退出登录方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
@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.授权
未完待续。。。