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机制
- 用户首次访问 Web 应用时,服务器创建一个 Session,并为该 Session 分配一个唯一的标识符(Session ID)。
- 服务器将 Session ID 作为 Cookie 的值发送到用户的浏览器。
- 用户在后续的请求中,浏览器会自动将包含 Session ID 的 Cookie 发送回服务器。
- 服务器通过 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/