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.授权
未完待续。。。