简介
Spring Security 是 Spring 家族中的一个安全管理框架。目前比较主流的是另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富,但是shiro并不简便,这里轻量级安全框架更推荐国产安全框架satokensatoken官网
一般大型的项目都是使用SpringSecurity 来做安全框架。这些安全框架主要的内容包含以下功能模块
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
比如:购买东西前需要登录 ,预约前需要登录认证个人信息
授权:经过认证后判断当前用户是否有权限进行某个操作
比如;一般某些管理后台系统对应用户的角色(管理员,审核,测试)等不同角色提供不同服务,某些接口功能只对某些特定角色开启
如果需要直接好整合在项目前后端分离场景 ,可以直接看副标题
本章主要讲认证模块 看完本章是实现前后端分离场景–>看这个Spring
security用户授权
而认证和授权也是SpringSecurity作为安全框架的核心功能。(官方中文文档截图)springsecurity官方中文文档
当然,这些功能也可以自己通过拦截器和jwt等实现认证,通过访问接口前多表联查实现授权等自定义实现,比如自己实现前后端分离安全认证
身份认证模块
官方代码示例:GitHub - spring-projects/spring-security-samples
目前主流的还是mvc架构 以及采用servlet的web项目
该目录地址就是一个推荐的springsecurity官方案列
项目名:security-demo
JDK:17
SpringBoot:3.2.0(依赖了Spring Security 6.2.0)
Dependencies:Spring Web、Spring Security、Thymeleaf
入门案列–静态密码登录 内存用户器
创建IndexController
package com.atguigu.securitydemo.controller;
@Controller
public class IndexController {
@GetMapping("/")
public String index() {
return "index";
}
}
创建index.html
在路径resources/templates中创建index.html,这个th:href=“@{/logout}” 是thymeleaf的模板语法和vue和相似
<html xmlns:th="https://www.thymeleaf.org">
<head>
<title>Hello Security!</title>
</head>
<body>
<h1>Hello Security</h1>
<!--通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。
这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。-->
<a th:href="@{/logout}">Log Out</a>
</body>
</html>
启动项目测试Controller
浏览器中访问:http://localhost:8080/
**浏览器自动跳转到登录页面:**http://localhost:8080/login
这个时候如果没有认证访问controller的路由就会跳转默认生成的登录页面
输入用户名:user
输入密码:在控制台的启动日志中查找初始的默认密码
点击"Sign in"进行登录,浏览器就跳转到了index页面
@{/logout}的作用
就是类似vue的动态语法 这里了解即可
通过使用@{/logout},Thymeleaf将自动处理生成正确的URL,以适应当前的上下文路径。这样,无论应用程序部署在哪个上下文路径下,生成的URL都能正确地指向注销功能。
例如:如果我们在配置文件中添加如下内容
server.servlet.context-path=/demo
那么@{/logout}可以自动处理url为正确的相对路径(此时为demo 这里是演示路径无需添加)
但是如果是普通的/logout,路径就会不正确
页面样式无法加载的问题
页面样式bootstrap.min.css是一个CDN地址,由于在国外需要通过科学上网的方式访问
当然由于我们写项目都是客户端和服务端分离的方式,这里无需在意这个问题
Spring Security默认做了什么
我们并没有写一个登录接口,但是框架就已经
- 保护应用程序URL,要求对应用程序的任何交互进行身份验证。
- 程序启动时生成一个默认用户“user”。
- 生成一个默认的随机密码,并将此密码记录在控制台上。
- 生成默认的登录表单和注销页面。
- 提供基于表单的登录和注销流程。
- 对于Web请求,重定向到登录页面;
- 对于服务请求,返回401未经授权。
- 处理跨站请求伪造(CSRF)攻击。
- 处理会话劫持攻击。
- 写入Strict-Transport-Security以确保HTTPS。
- 写入X-Content-Type-Options以处理嗅探攻击。
- 写入Cache Control头来保护经过身份验证的资源。
- 写入X-Frame-Options以处理点击劫持攻击。
Spring Security 的底层架构
官方文档中写得清楚
客户端向应用程序发送一个请求,容器创建一个 FilterChain,其中包含 Filter 实例和 Servlet,应该根据请求URI的路径来处理 HttpServletRequest。在Spring MVC应用程序中,Servlet是 DispatcherServlet 的一个实例。一个 Servlet 最多可以处理一个 HttpServletRequest 和 HttpServletResponse。然而,可以使用多个 Filter 来完成如下工作。
防止下游的 Filter 实例或 Servlet 被调用。在这种情况下,Filter 通常会使用 HttpServletResponse 对客户端写入响应。
修改下游的 Filter 实例和 Servlet 所使用的 HttpServletRequest 或 HttpServletResponse。
过滤器的力量来自于传入它的 FilterChain。
DelegatingFilterProxy
DelegatingFilterProxy 是 Spring Security 提供的一个 Filter 实现,可以在 Servlet 容器和 Spring 容器之间建立桥梁。通过使用 DelegatingFilterProxy,这样就可以将Servlet容器中的 Filter 实例放在 Spring 容器中管理。
FilterChainProxy
复杂的业务中不可能只有一个过滤器。因此FilterChainProxy是Spring Security提供的一个特殊的Filter,它允许通过SecurityFilterChain将过滤器的工作委托给多个Bean Filter实例。
SecurityFilterChain
SecurityFilterChain 被 FilterChainProxy 使用,负责查找当前的请求需要执行的Security Filter列表。
Multiple SecurityFilterChain
多个过滤器的使用可以完成各个样子的业务
可以有多个SecurityFilterChain的配置,FilterChainProxy决定使用哪个SecurityFilterChain。如果请求的URL是/api/messages/,它首先匹配SecurityFilterChain0的模式/api/**,因此只调用SecurityFilterChain 0。假设没有其他SecurityFilterChain实例匹配,那么将调用SecurityFilterChain n。
所以看下来可以发现springsecurity中包含了很多过滤器,组成过滤器链完成认证操作
SecurityFilterChain接口的实现,加载了默认的16个Filter
SecurityProperties 配置静态用户名,密码
了解完securtiy的底层架构,在解决一个问题,那么就是登录时候的账户名和密码如何出现的,在依赖中搜索该类SecurityProperties,发现会出现一个内部类user,账户名为user,密码则是uuid,很明显这个就是启动项目时候默认生成的密码,既然这个类是属性类
,那么我们也可以进行指定,在security的配置文件文档中
编写配置
import org.springframework.beans.factory.annotation.Autowired;
import org.sprinramework.context.annotation.*;
import org.springframework.security.config.annotation.authentication.builders.*;
import org.springframework.security.config.annotation.web.configuration.*;
@Configuration
@EnableWebSecurity//SPRINGBOOT项目忽略
public class WebSecurityConfig {
@Bean
public UserDetailsService userDetailsService() {
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();
// 在内存中创建用户作用登录时候作为对比的数据源
manager.createUser(User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build());
return manager;
}
}
发现可以通过注册一个UserDetailsService的bean进行实现
其中返回的是内存管理对象实列,管理用户users
UserDetailsService 包含了根据用户名加载对象方法,该方法就是springsecurity进行用户比对的用户源,(这里是进行静态写在内存,那么后期可以自己实现这个接口来比对用户变成数据库)
其中的maner则是提供了对系统用户crud的方法
回到官网提供的demo配置案列,那么就可以将用户静态写在内存中
//见名知意 这里创建在内存的时候,还对这个用户密码进行了加密(withDefaultPasswordEncoder)
manager.createUser(User.withDefaultPasswordEncoder()
.username("user").password("password").roles("USER").build());
此时重启配置,就可以根据user ,password进行登录
既然是静态配置,那么就可以写在配置文件
spring.security.user.name=user
spring.security.user.password=123
Spring Security自定义配置
需要对于接口UserDetailsService有个初步映像,理解为管理认证需要的数据源管理器
Springscurity主要的是配置过滤器链
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// 忽略授权的地址列表
List<String> permitList = permitResource.getPermitList();
String[] permits = permitList.toArray(new String[0]);
http
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(permits).permitAll()
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated()
)
.exceptionHandling(exception -> exception.authenticationEntryPoint(new SecurityAuthenticationEntryPoint()))
.headers(headers -> headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::disable))
.csrf(AbstractHttpConfigurer::disable)
;
return http.build();
}
官方文档
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.authorizeRequests(authorize -> authorize
.anyRequest().authenticated()
)
.formLogin(withDefaults())
.httpBasic(withDefaults());
return http.build();
}`
基于内存配置
实际开发的过程中,我们需要应用程序更加灵活,可以在SpringSecurity中创建自定义配置文件
官方文档:Java自定义配置
刚才实现了官方的实列配置,可以发现大致是相同,那么我们可以通过了解基于内存的管理器来更一步了解security
从刚才的实列中可知.UserDetailsService用来管理用户信息,InMemoryUserDetailsManager是UserDetailsService的一个实现,用来管理基于内存的用户信息。其中createuser就是在内存中创建系统用户,用于登录时候进行比对
实现就是在其封装在内存中map,
manner的其他方法则是对用户进行crud 不做过多阐述
那么顶级接口userdetailsService呢
loadUserByUsername 这个方法主要用于从后端系统(比如数据库,官方演示配置是内存)加载用户的详细信息。当用户尝试登录时,他们会提供自己的用户名(或其他标识)和密码。Spring Security 需要使用这个用户名来获取用户的详细信息,包括他们的密码、权限等。这就是在 UserDetailsService 接口中定义的 loadUserByUsername 方法的目的。也就是说在进行比对认证时候需要调用这个方法
loadUserByUsername 就是在尝试进行用户认证的过程中,从后端系统加载用户详细信息的关键步骤。这个步骤通常发生在 Spring Security 处理登录请求的过程中的 DaoAuthenticationProvider 中。
这里查看内存用户管理器的实现 通过用户名返回用户
在UsernamePasswordAuthenticationFilter
过滤器中的attemptAuthentication
方法中将用户输入的用户名密码和从内存中获取到的用户信息进行比较,进行用户认证
认证的大概逻辑
而这个用户我没并没有定义,这个用户是官方定义的用户类型
通过源码可以看到官方定义的用户 包含了用户基本信息外,还有权限列表账户是否过期等消息,所以在创建用户的时候大致可以参考一下其中user,而userdetials则是定影了用户的一些细节信息
实现security登录接口基于数据库
所以接下来通过改造重写userdetialsuservice实现数据库认证
准备工作
创建数据并且创建三个用户
-- 创建数据库
CREATE DATABASE `security-demo`;
USE `security-demo`;
-- 创建用户表
CREATE TABLE `user`(
`id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
`username` VARCHAR(50) DEFAULT NULL ,
`password` VARCHAR(500) DEFAULT NULL,
`enabled` BOOLEAN NOT NULL
);
-- 唯一索引
CREATE UNIQUE INDEX `user_username_uindex` ON `user`(`username`);
-- 插入用户数据(密码是 "abc" )
INSERT INTO `user` (`username`, `password`, `enabled`) VALUES
('admin', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE),
('Helen', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE),
('Tom', '{bcrypt}$2a$10$GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW', TRUE);
字段也只有三个,是很基本的用户表,其中数据密码是采用springsecurity的默认加密方式
引入依赖
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.30</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.5</version>
<exclusions>
<exclusion>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
</exclusion>
</exclusions>
</dependency>
<!--swagger测试-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
<dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis-spring</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
配置数据源
#MySQL数据源
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
spring.datasource.url=jdbc:mysql://localhost:3306/security-demo
spring.datasource.username=root
spring.datasource.password=123456
#SQL日志
mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl
安装myvatis-x 一键生成各个层
写一个controller验证orm框架是否操作数据库可行
@RestController
@RequestMapping("/user")
public class UserController {
@Resource
public UserService userService;
@GetMapping("/list")
public List<User> getList(){
return userService.list();
}
}
目录
将对比用户数据源定义为自己的数据库用户管理器
创建认证管理器有内存和jdbc俩个,但是jdbc的认证是基于springtemplate 所以需要自己更改
通过上面的认识,认证过程中主要是通过loadbyusername 取出用户进行和前端输入的用户比对,所以要做的就是模仿InMemoryUserDetailsManager类
基于数据库的用户认证实现
认证流程
- 程序启动时:
模仿InMemoryUserDetailsManager- 创建
DBUserDetailsManager
类,实现接口 UserDetailsManager, UserDetailsPasswordService - 在应用程序中初始化这个类的对象
- 创建
- 校验用户时:
- SpringSecurity自动使用
DBUserDetailsManager
的loadUserByUsername
方法从数据库中
获取User对象 - 在
UsernamePasswordAuthenticationFilter
过滤器中的attemptAuthentication
方法中将用户输入的用户名密码和从数据库中获取到的用户信息进行比较,进行用户认证
- SpringSecurity自动使用
@Component//注入ioc 或者在配置文件中用@Bean的方式注入
@Slf4j
public class DBUserDetailsManager implements UserDetailsManager, UserDetailsPasswordService {
// 这样就可以按照security的规范来使用用户的管理
@Override
public UserDetails updatePassword(UserDetails user, String newPassword) {
return null;
}
//原来的内存管理器是在用户在添加到内存的map,实现这个方法这里插入数据库
@Override
public void createUser(UserDetails userDetails) {
// 在sql中插入信息
User user = new User();
user.setUsername(userDetails.getUsername());
user.setPassword(userDetails.getPassword());
user.setEnabled(1);
userMapper.insert(user);
}
@Override
public void updateUser(UserDetails user) {
}
@Override
public void deleteUser(String username) {
}
@Override
public void changePassword(String oldPassword, String newPassword) {
}
@Override
public boolean userExists(String username) {
return false;
}
@Resource
private UserMapper userMapper;
//security底层会根据这个方法来对比用户
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = Wrappers.<User>lambdaQuery().eq(User::getUsername, username);
// 这里用户账户是唯一的
User user = userMapper.selectOne(wrapper);
if (user == null){
throw new UsernameNotFoundException("系统用户不存在");
}else{
// 1表示可用
boolean isenabled = user.getEnabled() == 1;
/**
* ,任何非零的整数值都会被视为 true,而 0 会被视为 false。
*/
//模拟系统权限列表
Collection<GrantedAuthority> authorities = new ArrayList<>();
return new org.springframework.security.core.userdetails.User(user.getUsername(), user.getPassword(),isenabled ,
true,
true,
true,
// 权限列表
authorities);
}
}
}
重启的时候把配置类中基于内存的用户管理器注入bean的代码注释,避免有俩个userdetailsService的实现,认证时调用loaduserBYusername冲突,重启即可,此时登录认证,输入admin,password 即可登录,则说明是从数据库中获取用户进行比对且成功
此时已经是实现基于数据库实现,但是还是不够灵活,为此我们需要了解默认配置(引入security即实现的配置)
//经过过滤器的请求
http
// lambda表达式对其中请求进行遍历
.authorizeRequests(authorize -> authorize
.anyRequest()
.authenticated()//已认证的请求自动授权
)
// 如果没有登录认证的请求默认使用表单登录api 跳转表单进行登录
.formLogin(withDefaults())//自动生成表单
.httpBasic(withDefaults());//然后给在使用基本授权方式(游览器默认表单)
httpBasic(withDefaults())采用游览器默认认证方式,在过滤器链中注释.formLogin(withDefaults()) 那么重启
默认配置的另一个配置表单登录,如果我们觉得,这个登录页面丑呢,那么需要修改这个配置来实现自定义登录界面(这里都是前后i端一体的,分离在后面)
自定义security的登录界面
在templates新建login页面
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录页面</title>
<style>
* {
margin: 0;
padding: 0;
}
html {
height: 100%;
}
body {
height: 100%;
}
.container {
height: 100%;
background-image: linear-gradient(to right, #fbc2eb, #a6c1ee);
}
.login-wrapper {
background-color: #fff;
width: 358px;
height: 588px;
border-radius: 15px;
padding: 0 50px;
position: relative;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
}
.header {
font-size: 38px;
font-weight: bold;
text-align: center;
line-height: 200px;
}
.input-item {
display: block;
width: 100%;
margin-bottom: 20px;
border: 0;
padding: 10px;
border-bottom: 1px solid rgb(128, 125, 125);
font-size: 15px;
outline: none;
}
.input-item:placeholder {
text-transform: uppercase;
}
.btn {
text-align: center;
padding: 10px;
width: 100%;
margin-top: 40px;
background-image: linear-gradient(to right, #a6c1ee, #fbc2eb);
color: #fff;
}
.msg {
text-align: center;
line-height: 88px;
}
a {
text-decoration-line: none;
color: #abc1ee;
}
</style>
</head>
<body>
<div class="container">
<div class="login-wrapper">
<div class="header">Login</div>
<!--method必须为"post"-->
<!--th:action="@{/login}" ,
使用动态参数,表单中会自动生成_csrf隐藏字段,用于防止csrf攻击
login: 和登录页面保持一致即可,SpringSecurity自动进行登录认证-->
<form th:action="@{/login}" method="post" class="form-wrapper">
<div>
<!--name必须为"username"-->
<input type="text" name="username" placeholder="用户名" class="input-item"/>
</div>
<div>
<!--name必须为"password"-->
<input type="password" name="password" placeholder="密码" class="input-item"/>
</div>
<input type="submit" value="登录" class="btn" />
</form>
<div th:if="${param.error}">
错误的用户名和密码.</div>
<div class="msg">
Don't have account?
<a href="#">Sign up</a>
</div>
</div>
</div>
</body>
</html>
新添加一个controller负责跳转路由
@Controller
public class LoginController {
@GetMapping("/login")
public String login() {
return "login";
}
}
修改配置文件
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
// 返回的是安全过滤器链所以是依次执行的
//关闭csrf攻击防御
http.csrf(AbstractHttpConfigurer::disable).formLogin( form -> {
form
.loginPage("/login").permitAll() //登录页面无需授权即可访问
.usernameParameter("username") //自定义表单用户名参数,默认是username
.passwordParameter("password") //自定义表单密码参数,默认是password
.failureUrl("/login?error") //登录失败的返回地址
;
}); //使用表单授权方式;
http
// lambda表达式对其中请求进行遍历
.authorizeRequests(authorize -> authorize
.anyRequest()
.authenticated()//已认证的请求自动授权
)
// 如果没有登录认证的请求默认使用表单登录api 跳转表单进行登录
// .formLogin(withDefaults())//自动生成表单 不使用自动生成的表单
.httpBasic(withDefaults());//然后给在使用基本授权方式(游览器默认表单)
return http.build();
}
一定记得关闭默认表达认证避免重涂,重启项目
那么实现完成了自定义登录了,接下来探讨的是密码安全部分
密码安全
security在密码安全部分做了很好的加密算法
先回忆内存用户管理类中添加用户的代码,其中对用户进行了加密
为此我们根据官方实列来进行仿造数据中添加符合security定义的用户
usercontroller中写一个添加用户接口
@PostMapping("/add")
public void add(@RequestBody User user){ userService.saveUserDetails(user);}
UserService接口中添加方法
void saveUserDetails(User user);
UserServiceImpl实现中添加方法
@Slf4j
@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User>
implements UserService{
@Autowired
DBUserDetailsManager userDetailsManager;
@Override
public void adduser(User user) {
log.info("最开始接收到的密码"+user.getPassword());
// security的user
UserDetails details = org.springframework.security.core.userdetails.User
.withDefaultPasswordEncoder().username(user.getUsername()).password(user.getPassword())
.roles("USER")//当前数据还没有角色一说
.build();
log.info("构造为userdetials的密码"+details.getPassword());
userDetailsManager.createUser(details);
}
}
DBUserDetailsManager中之前就添加的插入数据库方法
@Override
public void createUser(UserDetails userDetails) {
User user = new User();
user.setUsername(userDetails.getUsername());
user.setPassword(userDetails.getPassword());
user.setEnabled(true);
userMapper.insert(user);
}
使用Swagger测试
pom中添加配置用于测试
<!--swagger测试-->
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.1.0</version>
</dependency>
**Swagger测试地址:**http://localhost:8080/demo/doc.html
在测试接口前需要关闭csrf保护
默认情况下SpringSecurity开启了csrf攻击防御的功能,这要求请求参数中必须有一个隐藏的**_csrf**字段,如下:
在filterChain方法中添加如下代码,关闭csrf攻击防御
//关闭csrf攻击防御
http.csrf((csrf) -> {
csrf.disable();
});
访问swagger接口测试
http://localhost:8080/doc.html
输出
此时就可以清楚的看到密码是进行加密了的
密码加密算法
参考文档:Password Storage :: Spring Security
密码加密方式
明文密码:
最初,密码以明文形式存储在数据库中。但是恶意用户可能会通过SQL注入等手段获取到明文密码,或者程序员将数据库数据泄露的情况也可能发生。
Hash算法:
Spring Security的PasswordEncoder
接口用于对密码进行单向转换
,从而将密码安全地存储。对密码单向转换需要用到哈希算法
,例如MD5、SHA-256、SHA-512等,哈希算法是单向的,只能加密,不能解密
。
因此,数据库中存储的是单向转换后的密码
,Spring Security在进行用户身份验证时需要将用户输入的密码进行单向转换,然后与数据库的密码进行比较。
因此,如果发生数据泄露,只有密码的单向哈希会被暴露。由于哈希是单向的,并且在给定哈希的情况下只能通过暴力破解的方式猜测密码
。
彩虹表:
恶意用户创建称为彩虹表
的查找表。
彩虹表就是一个庞大的、针对各种可能的字母组合预先生成的哈希值集合,有了它可以快速破解各类密码。越是复杂的密码,需要的彩虹表就越大,主流的彩虹表都是100G以上,目前主要的算法有LM, NTLM, MD5, SHA1, MYSQLSHA1, HALFLMCHALL, NTLMCHALL, ORACLE-SYSTEM, MD5-HALF。
加盐密码:
为了减轻彩虹表的效果,开发人员开始使用加盐密码。不再只使用密码作为哈希函数的输入,而是为每个用户的密码生成随机字节(称为盐)。盐和用户的密码将一起经过哈希函数运算,生成一个唯一的哈希。盐将以明文形式与用户的密码一起存储。然后,当用户尝试进行身份验证时,盐和用户输入的密码一起经过哈希函数运算,再与存储的密码进行比较。唯一的盐意味着彩虹表不再有效,因为对于每个盐和密码的组合,哈希都是不同的。
自适应单向函数:
随着硬件的不断发展,加盐哈希也不再安全。原因是,计算机可以每秒执行数十亿次哈希计算。这意味着我们可以轻松地破解每个密码。
现在,开发人员开始使用自适应单向函数来存储密码。使用自适应单向函数验证密码时,故意占用资源(故意使用大量的CPU、内存或其他资源)
。自适应单向函数允许配置一个“工作因子”
,随着硬件的改进而增加。我们建议将“工作因子”调整到系统中验证密码需要约一秒钟的时间。这种权衡是为了让攻击者难以破解密码
。
自适应单向函数包括bcrypt、PBKDF2、scrypt和argon2
。
PasswordEncoder
BCryptPasswordEncoder
使用广泛支持的bcrypt算法来对密码进行哈希。为了增加对密码破解的抵抗力,bcrypt故意设计得较慢。和其他自适应单向函数一样,应该调整其参数,使其在您的系统上验证一个密码大约需要1秒的时间。BCryptPasswordEncoder的默认实现使用强度10。建议您在自己的系统上调整和测试强度参数,以便验证密码时大约需要1秒的时间。
Argon2PasswordEncoder
使用Argon2算法对密码进行哈希处理。Argon2是密码哈希比赛的获胜者。为了防止在自定义硬件上进行密码破解,Argon2是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当前的Argon2PasswordEncoder实现需要使用BouncyCastle库。
Pbkdf2PasswordEncoder
使用PBKDF2算法对密码进行哈希处理。为了防止密码破解,PBKDF2是一种故意缓慢的算法。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。当需要FIPS认证时,这种算法是一个很好的选择。
SCryptPasswordEncoder
使用scrypt算法对密码进行哈希处理。为了防止在自定义硬件上进行密码破解,scrypt是一种故意缓慢的算法,需要大量内存。与其他自适应单向函数一样,它应该在您的系统上调整为大约1秒来验证一个密码。
密码加密测试
在测试类中编写一个测试方法
@Test
void testPassword() {
// 工作因子,默认值是10,最小值是4,最大值是31,值越大运算速度越慢
PasswordEncoder encoder = new BCryptPasswordEncoder(4);
//明文:"password"
//密文:result,即使明文密码相同,每次生成的密文也不一致
String result = encoder.encode("password");
System.out.println(result);
//密码校验
Assert.isTrue(encoder.matches("password", result), "密码不一致");
}
DelegatingPasswordEncoder
- 表中存储的密码形式:
{bcrypt}
$2a 10 10 10GRLdNijSQMUvl/au9ofL.eDwmoohzzS7.rmNSJZ.0FxO/BTk76klW - 通过如下源码可以知道:可以通过
{bcrypt}
前缀动态获取和密码的形式类型一致的PasswordEncoder对象 - 目的:方便随时做密码策略的升级,兼容数据库中的老版本密码策略生成的密码
根据密码前缀进行比对
前缀是为了判断根据哪个算法进行加密,对用户密码进行比对时候会判断前缀 不同用户不同加密方式
身份认证模块-前后端分离架构
在项目开发中前端和后端应该是分开的特别是服务器端应该专注于数据的返回,而页面跳转等前端工作由前端完成,所以这里需要对认证功能进行前后端分离开发的i情况下定制化
先来了解登录流程
登录流程
下面讨论的都是security的内置部分
- 登录成功后调用:AuthenticationSuccessHandler
- 登录失败后调用:AuthenticationFailureHandler
所以我们需要做的就是- 前端传递的用户密码生成认证token 然后提交给认证manager,所以我们的登录接口返回的不应该是跳转路由
参考我们正常的前后端分离开发过程,我们需要自己封装一个json
首先关闭下面的配置
// 只要关闭了这几个配置,就不会采用security的登录方案,就可以自己写登录逻辑
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // 使用最新的Lambda风格禁用CSRF保护
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated() // 要求所有访问都必须通过认证
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不创建会话
);
return http.build();
}
自定义返回结果的话就需要重写 认证处理成功的处理器的抽象方法
同理认证失败的话就要重写认证失败处理器中的处理方法
所以在实现先后端分离的效果之前,先了解springsecurity中表单登录自定义的成功和失败的相应
认证返回结果为json
引入fastjson
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.37</version>
</dependency>
写一个通用返回结果类(这里随便写的实际开发不可能这么简单)
@Data
@ApiModel(value = "通用返回类型")
public class R<T> {
@ApiModelProperty(value = "编码 0表示成功,其他值表示失败")
private int code = 0;
@ApiModelProperty( "消息内容")
private String msg = "success";
@ApiModelProperty( "响应数据")
private T data;
public static <T> R<T> ok(String loginSuccess) {
R<T> r = new R<T>();
r.setCode(0);
r.setMsg(loginSuccess);
return r;
}
public static <T> R<T> ok(T data) {
R<T> R = new R<>();
R.setData(data);
return R;
}
public static <T> R<T> ok(String msg,T data) {
R<T> R = new R<>();
R.setMsg(msg);
R.setData(data);
return R;
}
public static <T> R<T> error() {
return error(ErrorCode.INTERNAL_SERVER_ERROR);
}
public static <T> R<T> error(String msg) {
return error(ErrorCode.INTERNAL_SERVER_ERROR.getCode(), msg);
}
public static <T> R<T> error(ErrorCode errorCode) {
return error(errorCode.getCode(), errorCode.getMsg());
}
public static <T> R<T> error(int code, String msg) {
R<T> R = new R<>();
R.setCode(code);
R.setMsg(msg);
return R;
}
}
表单登录的认证成功的响应
写一个响应成功类
@Slf4j
public class MyAuthenSuccessHandler implements AuthenticationSuccessHandler
{
@Resource
UserMapper userMapper;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
throws IOException, ServletException {
// 当应用程序认证成功时候触发
User user = new User();
Object principal = authentication.getPrincipal(); //获取用户身份信息
log.info("用户信息{}", principal);
// authentication.getCredentials();//登录凭证信息 账户密码登录 时里包含用户密码等信息
// Collection<? extends GrantedAuthority> collection = authentication.getAuthorities();//包含的权限信息
response.setContentType("application/json;charset=UTF-8");//响应头
//认证成功
String jsonString = JSON.toJSONString(R.ok(principal));
response.getWriter().println(jsonString);//响应体内容输出
}
}
SecurityFilterChain配置
form.successHandler(new MyAuthenticationSuccessHandler()) //认证成功时的处理
输出的principal中不包含密码
返回结果
表单登录的认证失败响应
失败结果处理
public class MyAuthenticationFailureHandler implements AuthenticationFailureHandler {
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException, IOException, IOException {
//获取错误信息
String localizedMessage = exception.getLocalizedMessage();
//转换成json字符串
String json = JSON.toJSONString(R.error("登录失败哦"));
//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}
SecurityFilterChain配置
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable) // 使用最新的Lambda风格禁用CSRF保护
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated() // 要求所有访问都必须通过认证
)
// 自定义认证成功处理器和认证失败处理器
.formLogin(form -> form
.successHandler(new MyAuthenSuccessHandler()) // 设置自定义认证成功处理器
.failureHandler(new MyAuthenticationFailureHandler())
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不创建会话
);
return http.build();
}
此时使用Springsecurity的表单登录效果就变成了自定义的json
表单登录的注销响应器
注销结果处理
package com.atguigu.securitydemo.config;
public class MyLogoutSuccessHandler implements LogoutSuccessHandler {
@Override
public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
//创建结果对象
HashMap result = new HashMap();
result.put("code", 0);
result.put("message", "注销成功");
//转换成json字符串
String json = JSON.toJSONString(result);
//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}
SecurityFilterChain配置
http.logout(logout -> {
logout.logoutSuccessHandler(new MyLogoutSuccessHandler()); //注销成功时的处理
});
但是这样写处理器是有一点麻烦的 Springsecurity 有事件监听机制
可以通过事件监听器(Event Listener)来处理登录成功和失败的事件,而无需单独实现 AuthenticationSuccessHandler 和 AbstractAuthenticationFailureEvent。事件监听器可以更为灵活地处理认证成功和失败的情况,并且可以集中处理多种事件,包括但不限于认证成功和失败。
使用事件监听器处理认证事件
Spring Security提供了几个事件,其中包括 AuthenticationSuccessEvent 和 AbstractAuthenticationFailureEvent,它们可以用来监听认证成功和认证失败的事件。通过创建事件监听器,你可以在这些事件发生时执行自定义逻辑。 只需要注入ioc ,无需写配置文件
@Component
@AllArgsConstructor
public class AuthenticationEvents {
private final SysLogLoginService sysLogLoginService;
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
// 用户信息
UserDetail user = (UserDetail) event.getAuthentication().getPrincipal();
// 保存登录日志
sysLogLoginService.save(user.getUsername(), Constant.SUCCESS, LoginOperationEnum.LOGIN_SUCCESS.getValue());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
// 用户名
String username = (String) event.getAuthentication().getPrincipal();
// 保存登录日志
sysLogLoginService.save(username, Constant.FAIL, LoginOperationEnum.ACCOUNT_FAIL.getValue());
}
}
上面的几个认证相关的处理器都是SpringSecurity自身集成的处理器 ,但是我们实际开发前后端分离的项目,往往是不需要后端写登录表单的,认证成功的接口相应在controller处理,未认证的接口写一个统一个认证失败端点(endpoint接口即可)
请求未认证的接口
实现AuthenticationEntryPoint接口
Servlet Authentication Architecture :: Spring Security
当访问一个需要认证之后才能访问的接口的时候,Spring Security会使用AuthenticationEntryPoint
将用户请求跳转到登录页面,要求用户提供登录凭证。所以如果是前后端分类使用Springsecurity,一定要重写这个接口并用配置文件加入过滤器链
这里我们也希望系统返回json结果
方便前端根据状态码进行跳转客户端的登录页面,因此我们定义类实现AuthenticationEntryPoint接口
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType("application/json; charset=utf-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
// response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
response.getWriter().print(JSON.toJSONString(R.error(ErrorCode.UNAUTHORIZED)));
}
}
SecurityFilterChain配置 :关闭表单相应的处理以后,走的这是这个认证失败端点处理器
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationSuccessHandler myAuthenSuccessHandler, AuthenticationFailureHandler myAuthenticationFailureHandler) throws Exception {
http
.csrf(csrf -> csrf.disable()) // 使用最新的Lambda风格禁用CSRF保护
.authorizeHttpRequests(authorize -> authorize
.anyRequest().authenticated() // 要求所有访问都必须通过认证
)
.exceptionHandling(exception -> exception.authenticationEntryPoint(new SecurityAuthenticationEntryPoint()))
// 如果你仍然需要支持基于表单的登录,可以保留这部分配置,并确保处理器返回JSON
// .formLogin(form -> form
// .successHandler(myAuthenSuccessHandler) // 设置自定义认证成功处理器
// .failureHandler(myAuthenticationFailureHandler)
// )
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不创建会话
);
return http.build();
}
再次重述一边,如果需要关掉Springsecurity自带的表单登录(避免未认证请求被重定向到默认的登录页面),就需要写这个统一的认证失败端点的处理器,不然请求走的还是Springsecurity自带的表单认证处理器们
用户认证信息封装
基本概念
官网解析
在Spring Security框架中,SecurityContextHolder、SecurityContext、Authentication、Principal和Credential是一些与身份验证和授权相关的重要概念。它们之间的关系如下:
- SecurityContextHolder:SecurityContextHolder (上下文管理器)是 Spring Security 存储已认证用户详细信息的地方。
- SecurityContext:SecurityContext (上下文)是从 SecurityContextHolder 获取的内容,包含当前已认证用户的 Authentication 信息。
- Authentication:Authentication 表示用户的身份认证信息。它包含了用户的Principal、Credential和Authority信息。
- Principal:表示用户的身份标识。它通常是一个表示用户的实体对象,例如用户名。Principal可以通过Authentication对象的getPrincipal()方法获取。
- Credentials:表示用户的凭证信息,例如密码、证书或其他认证凭据。Credential可以通过Authentication对象的getCredentials()方法获取。
- GrantedAuthority:表示用户被授予的权限
总结起来,SecurityContextHolder用于管理当前线程的安全上下文,存储已认证用户的详细信息,其中包含了SecurityContext对象,该对象包含了Authentication对象,后者表示用户的身份验证信息,包括Principal(用户的身份标识)和Credential(用户的凭证信息)。
自己实现前后端登录安全校验,这篇文档中我使用的就是localtrhread 线程池作为上下文对象
在Controller中获取用户信息
之前实在官方的接口中获取配置信息,那么如果在controller的环境呢
其实就是类似上下文,在最开始的哪个
IndexController:
@GetMapping("/")
public Result index() {
SecurityContext context = SecurityContextHolder.getContext();//存储认证对象的上下文 源码也是threadlocal
Authentication authentication = context.getAuthentication();//认证对象
String username = authentication.getName();//用户名
Object principal =authentication.getPrincipal();//身份
Object credentials = authentication.getCredentials();//凭证(脱敏)
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();//权限
System.out.println(username);
System.out.println(principal);
System.out.println(credentials);
System.out.println(authorities);
HashMap<String, Object> map = new HashMap<>();
map.put("认证对象", authentication);
map.put("身份信息", principal);
map.put("creden", credentials);
return Result.success(map);
}
并且
如果把cookie删除,找不到对应的session,那么就会登录失效 说明security默认是采用会话登录,如果在分布式的环境中,session无法共享是不能完成我们需要的需求的,后续我们需要对该功能模块进行更细一步的定制化
会话并发处理
后登录的账号会使先登录的账号cookie失效,只适用于session做为登录凭证的情况
实现处理器接口
实现接口SessionInformationExpiredStrategy
package com.atguigu.securitydemo.config;
public class MySessionInformationExpiredStrategy implements SessionInformationExpiredStrategy {
//当session失效
@Override
public void onExpiredSessionDetected(SessionInformationExpiredEvent event) throws IOException, ServletException {
//创建结果对象
HashMap result = new HashMap();
result.put("code", -1);
result.put("message", "该账号已从其他设备登录");
//转换成json字符串
String json = JSON.toJSONString(result);
HttpServletResponse response = event.getResponse();
//返回响应
response.setContentType("application/json;charset=UTF-8");
response.getWriter().println(json);
}
}
SecurityFilterChain配置
//会话管理
http.sessionManagement(session -> {
session
只允许客户端匹配session 的token数量
.maximumSessions(1)
.expiredSessionStrategy(new MySessionInformationExpiredStrategy());
});
此时前一个登录的客户端cookie就过期了
如果是jwt的话可以采用redis,当前的jwt作为key,由于jwt是随机的所以也可以作为jwt+redis实现多设备排除登录的情况
跨域
跨域全称是跨域资源共享(Cross-Origin Resources Sharing,CORS),它是浏览器的保护机制,只允许网页请求统一域名下的服务,同一域名指=>协议、域名、端口号都要保持一致,如果有一项不同,那么就是跨域请求。在前后端分离的项目中,需要解决跨域的问题。
在SpringSecurity中解决跨域很简单,在配置文件中添加如下配置即可
//跨域
http.cors(withDefaults());
但是上诉提供实现的接口配置都是在原来security提供的login logout 等方法上,显然我们自己的登录接口需要更自由的定制
进阶定制化jwt,redis等定制化需求
刚才的接口实现就可以发现,官方默认的login接口逻辑无法满足我们需求,所以我们需要进一步定制化目前github有个低代码平台(maku-boot)官网对security的实现也很优秀可以下载学习
首先梳理登录流程,完成定制化登录
-
接收登录请求:用户的登录请求通常是一个包含用户名和密码的 HTTP POST 请求。这个请求被 Spring Security 的 UsernamePasswordAuthenticationFilter 捕获。如果你不想使用 Spring Security 默认的登录端点(如 /login),你可以自定义登录接口。
2.生成 Authentication Token:UsernamePasswordAuthenticationFilter 会根据请求中的用户名和密码创建一个 UsernamePasswordAuthenticationToken(未认证状态 用于在过滤链中进行认证)。 -
认证过程:这个未认证的 UsernamePasswordAuthenticationToken 会被传递给 AuthenticationManager 进行认证。AuthenticationManager 会调用配置的 AuthenticationProvider,通常是 DaoAuthenticationProvider,来验证用户名和密码。
-
加载用户详情:AuthenticationProvider 会使用配置的 UserDetailsService 来加载用户的详细信息(如权限),并进行密码的验证。
-
认证成功:如果认证成功,AuthenticationManager 会返回一个已认证的 Authentication 对象(包含用户的权限信息)给 UsernamePasswordAuthenticationFilter。
安全上下文:SecurityContextHolder 的 SecurityContext 会被更新为包含已认证 Authentication 对象,表示当前用户已经通过认证。
在次还是建议看一遍实现前后端分离登录,逻辑和springsecurity大致一样的
登录成功后->手动生成token返回前端,过滤器用于比对当前用户是否携带token,以及把用户信息保存localthread作为上下文对象,登出->删除该token,让其无法通过过滤器,而security自带的登录登出则是将用户信息保存到SecurityContext作为上下文
可以把token放在redis 可以通过删除redis的数据让用户的token手动失效
具体实现
<!--redis依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.0</version>
</dependency>
<!-- https://mvnrepository.com/artifact/javax.xml.bind/jaxb-api javax.xml.bind.DatatypeConverter类,这是 Java 6 和 Java 7 中的 JAXB (Java Architecture for XML Binding) API 的一部分。在 Java 8 及更高版本中,JAXB 已经被移动到 java.xml.bind 模块。 jwt工具类中会用到-->
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.4.0-b180830.0359</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
jwt 工具类
package com.salon.util;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.nio.charset.StandardCharsets;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
/**
* version: ${jwt.version} 0.12.5
* <dependency>
* <groupId>io.jsonwebtoken</groupId>
* <artifactId>jjwt-api</artifactId>
* <version>${jwt.version}</version>
* </dependency>
* <dependency>
* <groupId>io.jsonwebtoken</groupId>
* <artifactId>jjwt-impl</artifactId>
* <scope>runtime</scope>
* <version>${jwt.version}</version>
* </dependency>
* <dependency>
* <groupId>io.jsonwebtoken</groupId>
* <artifactId>jjwt-jackson</artifactId>
* </dependency>
*/
public class AppJwtUtil {
// TOKEN的有效期(秒)
private static final int TOKEN_TIME_OUT = 3600;
// 加密密钥(推荐至少32位字符长度)
private static final String TOKEN_ENCRY_KEY = "23xaxaxaxaee9c693446422cb8f84bea449fca3d23e45488454554778784141e9c693446422c";
// 最小刷新间隔(秒)
private static final int REFRESH_TIME = 300;
// JWT 签发者信息
private static final String ISSUER = "houhou";
// JWT 接收用户
private static final String AUDIENCE = "app";
// JWT 主题
private static final String SUBJECT = "system";
/**
* 生成Token
*
* @param id 用户ID
* @return JWT字符串
*/
public static String getToken(String id) {
return getToken(id, new HashMap<>());
}
/**
* 生成Token携带自定义信息
* // 如果你需要压缩payload,可以手动实现,因为直接压缩的方法已被弃用
* // 注意:通常情况下不需要对JWT进行压缩,除非有明确的需求。
* @param id 用户ID
* @param claimMaps 自定义信息
* @return JWT字符串
*/
public static String getToken(String id, Map<String, Object> claimMaps) {
long currentTime = System.currentTimeMillis();
return Jwts.builder().id(id)
.issuedAt(new Date(currentTime))
.subject(SUBJECT).issuer(ISSUER)
.setAudience(AUDIENCE)
// .compressWith(CompressionCodecs.GZIP)
.claims(claimMaps)
.signWith(generalKey()).expiration(new Date(currentTime + TOKEN_TIME_OUT * 1000))
.compact();
}
/**
* 解析Token获取Claims
*
* @param token JWT字符串
* @return Claims对象
*/
public static Claims getClaimsBody(String token) {
try {
return Jwts.parser()
.verifyWith(generalKey())
.build()
.parseSignedClaims(token)
.getPayload();
} catch (ExpiredJwtException e) {
return e.getClaims(); // 即使过期也可以获取Claims
} catch (JwtException e) {
throw new IllegalArgumentException("无效的Token: " + e.getMessage());
}
}
/**
* 校验Token是否过期
*
* @param claims JWT的Claims
* @return -1: 有效且无需刷新;0: 有效但需刷新;1: 已过期;2: 解析失败
*/
public static int verifyToken(Claims claims) {
if (claims == null) {
return 1; // 无效Token
}
try {
Date expiration = claims.getExpiration();
if (expiration.before(new Date())) {
return 1; // Token已过期
}
// 判断是否需要刷新
if ((expiration.getTime() - System.currentTimeMillis()) > REFRESH_TIME * 1000) {
return -1; // 无需刷新
} else {
return 0; // 需刷新
}
} catch (Exception e) {
return 2; // 解析失败
}
}
/**
* 生成加密密钥
*
* @return SecretKey对象
*/
private static SecretKey generalKey() {
return Keys.hmacShaKeyFor(TOKEN_ENCRY_KEY.getBytes(StandardCharsets.UTF_8));
}
/**
* 测试主函数
*/
public static void main(String[] args) {
String randomKey = UUID.randomUUID().toString().replace("-", "");
System.out.println("随机密钥: " + randomKey);
// 输入id生成Token
Map<String, Object> map = new HashMap<>();
map.put("name", "张三");
map.put("age", 18);
String token = AppJwtUtil.getToken(String.valueOf(1102L), map);
System.out.println("生成的Token: " + token);
// 解密Token
Claims claims = AppJwtUtil.getClaimsBody(token);
if (claims != null) {
System.out.println("解析出的ID: " + claims.get("jti"));
System.out.println("其他属性:");
claims.forEach((key, value) -> System.out.println(key + ":" + value));
} else {
System.out.println("Token无效或已过期");
}
}
}
redis 工具类 这里用来做过滤器的时候进行请求头比对,并且用于手动让用户下线
@AllArgsConstructor
//@Component
@Service//注入到spring 容器中 等效于 @Service @Component
public class RedisCacheService {
private final RedisTemplate<String, Object> redisTemplate;
// ================== Expiration Constants ==================
public static final long DEFAULT_EXPIRE = 60 * 60 * 24L; // 24 hours
public static final long HOUR_ONE_EXPIRE = 60 * 60; // 1 hour
public static final long HOUR_SIX_EXPIRE = 60 * 60 * 6; // 6 hours
public static final long NOT_EXPIRE = -1L; // No expiration
// ================== Private Helper Methods ==================
private void setExpire(String key, long expire) {
if (expire != NOT_EXPIRE) {
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
}
private void setWithExpire(String key, Object value, long expire) {
redisTemplate.opsForValue().set(key, value);
setExpire(key, expire);
}
// ================== Value Operations ==================
public void set(String key, Object value, long expire) {
setWithExpire(key, value, expire);
}
public void set(String key, Object value) {
setWithExpire(key, value, DEFAULT_EXPIRE);
}
public Object get(String key) {
return redisTemplate.opsForValue().get(key);
}
public Long increment(String key) {
return redisTemplate.opsForValue().increment(key);
}
public Boolean hasKey(String key) {
return redisTemplate.hasKey(key);
}
public void delete(String key) {
redisTemplate.delete(key);
}
public void delete(Collection<String> keys) {
redisTemplate.delete(keys);
}
// ================== Hash Operations ==================
public Object hGet(String key, String field) {
return redisTemplate.opsForHash().get(key, field);
}
public Map<Object, Object> hGetAll(String key) {
return redisTemplate.opsForHash().entries(key);
}
public void hSet(String key, String field, Object value, long expire) {
redisTemplate.opsForHash().put(key, field, value);
setExpire(key, expire);
}
public void hMSet(String key, Map<String, Object> map, long expire) {
redisTemplate.opsForHash().putAll(key, map);
setExpire(key, expire);
}
public void hDel(String key, Object... fields) {
redisTemplate.opsForHash().delete(key, fields);
}
// ================== List Operations ==================
public void leftPush(String key, Object value, long expire) {
redisTemplate.opsForList().leftPush(key, value);
setExpire(key, expire);
}
public Object rightPop(String key) {
return redisTemplate.opsForList().rightPop(key);
}
public List<Object> range(String key, long start, long end) {
return redisTemplate.opsForList().range(key, start, end);
}
// ================== Set Operations ==================
public void sAdd(String key, Object value, long expire) {
redisTemplate.opsForSet().add(key, value);
setExpire(key, expire);
}
public Set<Object> sMembers(String key) {
return redisTemplate.opsForSet().members(key);
}
public Boolean sIsMember(String key, Object value) {
return redisTemplate.opsForSet().isMember(key, value);
}
public Long sRemove(String key, Object... values) {
return redisTemplate.opsForSet().remove(key, values);
}
// ================== Sorted Set Operations ==================
public void zAdd(String key, Object value, double score, long expire) {
redisTemplate.opsForZSet().add(key, value, score);
setExpire(key, expire);
}
public Set<Object> zRange(String key, long start, long end) {
return redisTemplate.opsForZSet().range(key, start, end);
}
public Double zScore(String key, Object value) {
return redisTemplate.opsForZSet().score(key, value);
}
public Long zRemove(String key, Object... values) {
return redisTemplate.opsForZSet().remove(key, values);
}
// ================== Expiration Operations ==================
public void expire(String key, long expire) {
redisTemplate.expire(key, expire, TimeUnit.SECONDS);
}
public Long getExpire(String key) {
return redisTemplate.getExpire(key, TimeUnit.SECONDS);
}
// ================== Pipeline Operations ==================
/**
* executePipeline — Redis 流水线操作(Pipeline)
* 什么是流水线操作?
* 流水线操作是 Redis 的一种批量操作方式,可以让我们一次发送多个命令,
* 而不必等待每个命令的响应,所有命令将一起被发送,然后 Redis 会批量返回所有命令的结果。
* 使用流水线的好处是能够显著提升性能,尤其是当需要执行大量简单操作时,减少了网络延迟的影响。
*
* 在标准的 Redis 操作中,每发出一个命令后,都需要等待 Redis 服务器返回结果。
* 而通过流水线,可以一次性发出多个命令,然后一次性收集所有的结果,减少了请求和响应的时间,
* 从而提升了整体性能。
*
* 流水线操作的步骤:
* 发出命令:将多个 Redis 命令一次性发送给 Redis 服务器,而不等待每个命令的结果。
* 等待返回结果:Redis 服务器将所有命令的响应一次性返回给客户端。
* @param keys
* @return
*/
public List<Object> executePipeline(List<String> keys, List<Object> valuesToSet) {
return redisTemplate.executePipelined(new RedisCallback<List<Object>>() {
@Override
public List<Object> doInRedis(RedisConnection connection) {
List<Object> result = new ArrayList<>();
// 批量检测是否存在操作(EXISTS)
// 检测每个键是否存在
for (String key : keys) {
result.add(connection.exists(key.getBytes()));
}
// 批量读取操作(GET)
// 读取每个键的值
for (String key : keys) {
result.add(connection.get(key.getBytes()));
}
// 批量写入操作(SET)
// 如果提供了值列表,则设置每个键的值
if (valuesToSet != null && valuesToSet.size() == keys.size()) {
for (int i = 0; i < keys.size(); i++) {
connection.set(keys.get(i).getBytes(), valuesToSet.get(i).toString().getBytes());
}
}
// 批量修改操作(INCR)
// 对每个键执行自增操作(INCR)
for (String key : keys) {
// connection.del(key.getBytes());批量删除操作(DEL)
connection.incr(key.getBytes()); // Redis 键的自增
}
return result;
}
});
}
// ================== Stream Operations ==================
/**
* 2. fetchWithStream — 通过流式方式获取数据
* 什么是流式获取数据?
* 流式处理是一种以数据流的形式处理数据的方式。它可以让我们逐步处理数据,
* 避免一次性加载所有数据到内存中,特别适合处理大量数据的场景。
*
* 在 Redis 中,如果我们要处理大量的键,
* 使用流式获取(通过 Stream)能够避免将所有数据一次性读取到内存,防止内存溢出的问题。
* @param pattern
* @param clazz
* @return
* @param <T>
*/
public <T> List<T> fetchWithStream(String pattern, Class<T> clazz) {
Set<String> keys = redisTemplate.keys(pattern);
if (keys == null) return Collections.emptyList();
return keys.stream()
.map(this::get)
.filter(Objects::nonNull)
.map(clazz::cast)
.collect(Collectors.toList());
}
}
配置redis和你的jwt属性
spring:
data:
redis:
database: 1
host: 192.168.249.133
port: 6379
password: redis
#timeout: 6000ms # 连接超时时长(毫秒)
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://localhost:3306/security-demo
username: root
password: 111111
mybatis-plus:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
jwt:
data:
# jwt加密密钥
SECRET: sahksaklsjaasa
# jwt储存的请求头Authorization固定写法
tokenHeader: Authorization
# jwt的过期时间(60s*60min*24h*7day)
expiration: 604800
# jwt负载中拿到的头信息
tokenHead: Bearer
jwt 引入后我们思考需要做的事情 之前是security服务于web默认有登录接口和登出,但是服务于表单登录的,为此我们如果只是后端开发,返回给前端一些数据的话,这里就需要自定义的登陆接口,主要是实现流程中的第三步
首先重写loaduserByusername的类,安全校验时候就会根据这个方法取出数据比较之前是使用默认案列的方法实列化userdetails
User.withDefaultPasswordEncoder().username("user").password("password").roles("USER").build()
现在自己写一个实现该接口的类,让我们定义的用户符合security的规范
/**
* 自定义的 UserDetails 实现类,用于集成 Spring Security 和自定义用户模型。
*/
@Data
public class SecurityUser implements UserDetails {
private final User systemUser; // 系统中的用户对象
private final List<Role> authorities; // 用户的角色列表
/**
* 构造函数中传入角色列表,避免直接调用持久层。
* @param systemUser 系统中的用户对象
* @param roleMapper 角色映射器,用于从数据库中获取角色信息
*/
public SecurityUser(User systemUser, RoleMapper roleMapper) {
this.systemUser = systemUser;
this.authorities = loadAuthorities(systemUser.getId(), roleMapper); // 加载用户的权限信息
}
/**
* 从数据库加载用户的权限信息。
* @param userId 用户ID
* @param roleMapper 角色映射器,用于从数据库中获取角色信息
* @return 用户的角色列表
*/
private List<Role> loadAuthorities(Long userId, RoleMapper roleMapper) {
return roleMapper.selectList(new LambdaQueryWrapper<Role>()
.inSql(Role::getId, "SELECT role_id FROM user_role WHERE user_id = " + userId)); // 注意:这里应在mapper接口中使用参数化查询来提高安全性
}
/**
* 获取用户权限集合。
* @return 用户权限集合
*/
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// 将角色转换为权限列表
return authorities.stream()
.map(role -> new SimpleGrantedAuthority(role.getRoleName()))
.collect(Collectors.toList());
}
/**
* 获取用户的密码。
* @return 用户的密码
*/
@Override
public String getPassword() {
return systemUser.getPassword();
}
/**
* 获取用户名。
* @return 用户名
*/
@Override
public String getUsername() {
return systemUser.getUsername();
}
/**
* 检查账户是否未过期。
* @return 如果账户未过期,则返回 true
*/
@Override
public boolean isAccountNonExpired() {
return true; // 默认认为账户不会过期
}
/**
* 检查账户是否未锁定。
* @return 如果账户未锁定,则返回 true
*/
@Override
public boolean isAccountNonLocked() {
return true; // 默认认为账户不会被锁定
}
/**
* 检查密码是否未过期。
* @return 如果密码未过期,则返回 true
*/
@Override
public boolean isCredentialsNonExpired() {
return true; // 默认认为密码不会过期
}
/**
* 检查用户状态是否启用。
* @return 如果用户状态为启用,则返回 true
*/
@Override
public boolean isEnabled() {
// 库里的状态码是字符串 01可用 02不可用
return "01".equals(systemUser.getStatus());
}
}
或者给原有的User用户添加不存在的权限集合字段
@Data
@TableName("sys_user")
public class SysUser implements Serializable, UserDetails {
@TableId(value = "id",type = IdType.AUTO)
private Long id;
private String username;
private String password;
private String realName;
private String avatar;
private Integer gender;
private String email;
private String mobile;
private Integer superAdmin;
private Integer status;
private Integer deleted;
private Long creator;
private LocalDateTime createTime;
private Long updater;
private LocalDateTime updateTime;
@TableField(exist = false)
private Collection<String> Authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return Authorities.stream().map(SimpleGrantedAuthority::new).collect(Collectors.toSet());
}
@Override
public boolean isAccountNonExpired() {
return false;
}
@Override
public boolean isAccountNonLocked() {
return false;
}
@Override
public boolean isCredentialsNonExpired() {
return false;
}
@Override
public boolean isEnabled() {
// return false;
return getStatus().equals(1) ;
}
}
登录 login逻辑
梳理逻辑: 获取前端发送的用户数据后,我们不自己进行比对,依靠 AuthenticationManager进行认证(考察我写的步骤三),然后生成token,返回给前端,并且保存用户数据到redis,便于进行主动删除用户的token
为此第一步就是先实现token的过滤器,从请求头中过滤出token,然后去redis中读取当前的用户是否存在
SPringSecurity的拦截逻辑实现
过滤器
/**
* 认证过滤器
*
* @author hcy
*/
@Component
@AllArgsConstructor
public class AuthenticationTokenFilter extends OncePerRequestFilter {
private final RedisCacheService redisCacheService;
private final RoleMapper roleMapper; // 检验用户权限的时候注入用
/**
* 执行过滤逻辑。
* 该方法首先尝试从请求中获取访问令牌(accessToken),
* 如果令牌为空,则直接放行请求。
* 接着,它会尝试从Redis缓存中根据accessToken获取用户信息。
* 若成功获取用户信息,则设置认证信息到Spring Security的上下文中。
* 最后,继续执行过滤器链中的下一个过滤器。
*
* @param request 请求对象
* @param response 响应对象
* @param chain 过滤器链
* @throws ServletException Servlet异常
* @throws IOException IO异常
*/
@Override
protected void doFilterInternal(@NotNull HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String accessToken = TokenUtils.getAccessToken(request);
logger.info("从请求头中获取accessToken成功,accessToken为:" + accessToken);
// accessToken为空,表示未登录
if (StringUtils.isBlank(accessToken)) {
chain.doFilter(request, response);
return;
}
// 解密Token
Claims claims = AppJwtUtil.getClaimsBody(accessToken);
assert claims != null;
//-1: 有效且无需刷新;0: 有效但需刷新;1: 已过期;2: 解析失败
int verifyToken = AppJwtUtil.verifyToken(claims);
if (verifyToken!= -1) {
// 放行不注入上下文 交给前端跳转登录处理
chain.doFilter(request, response);
return;
}
// 拼接登录接口中的key 从缓存中获取用户信息 采用提取id 拼接的好处是 避免key过长并且可以限制多用户同时登录这个账户
String key= RedisPre.LOGIN_USER + claims.getId();
/**
* 从缓存中获取用户信息
* 保存到缓存的是用户json字符串对象
*/
Object o = redisCacheService.get(key);
if (Objects.isNull(o)) {
// 放行 请求头中没有token
logger.info("从缓存中获取用户信息token失败,token为空");
chain.doFilter(request, response);
return;
}
String jsonUserString = o.toString();
logger.info("从缓存中获取用户信息成功,用户信息为:" + jsonUserString);
/**
* 用户存在,设置认证信息
* 新建SecurityContext上下文,并设置SecurityContextHolder设置登录用户认证信息
*/
User currentUser = JSON.parseObject(jsonUserString, User.class); // 使用fastjson解析json字符串为User对象
SecurityUser securityUser = new SecurityUser(currentUser, roleMapper);
// 认证成功后,将用户信息放入SecurityContextHolder中 注入上下文
Authentication authentication = new UsernamePasswordAuthenticationToken(
securityUser, null, securityUser.getAuthorities());
/**
* 设置上下文成功后,该请求会被保存在SecurityContextHolder中,
* Spring Security就会自动对该请求进行权限放行
*/
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
}
}
涉及到的工具类
/**
* 对token进行相关操作的工具类
*/
public class TokenUtils {
/**
* 生成 uuid token (如果只是简单的一些认证可以用这个放在query参数)
*
*/
public static String generator() {
return UUID.fastUUID().toString(true);
}
/**
* 从当前线程的请求中获取 request 并从请求 AccessToken
*/
public static String getAccessToken() {
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
if (request == null) {
return null;
}
return getAccessToken(request);
}
/**
* 获取 AccessToken 过滤器时候用到
* 提取请求头中的 Authorization 或者 access_token (query方式请求参数)参数
*/
public static String getAccessToken(HttpServletRequest request) {
String accessToken = request.getHeader("Authorization");
if (StrUtil.isBlank(accessToken)) {
accessToken = request.getParameter("access_token");
}
return accessToken;
}
}
http工具类
/**
* 工具类,用于简化HTTP请求和响应的操作。
*/
public class HttpContextUtils {
/**
* 获取当前线程的HttpServletRequest对象。
*
* @return HttpServletRequest对象或null(如果没有当前请求)
*/
public static HttpServletRequest getHttpServletRequest() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return null;
}
return ((ServletRequestAttributes) requestAttributes).getRequest();
}
/**
* 获取当前线程的HttpServletResponse对象。
*
* @return HttpServletResponse对象或null(如果没有当前响应)
*/
public static HttpServletResponse getHttpServletResponse() {
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
if (requestAttributes == null) {
return null;
}
return ((ServletRequestAttributes) requestAttributes).getResponse();
}
/**
* 从HttpServletRequest中提取所有参数,并返回为Map。
* 忽略值为空白字符串的参数。
*
* @param request HTTP请求对象
* @return 参数映射,键是参数名,值是参数值
*/
public static Map<String, String> getParameterMap(HttpServletRequest request) {
Enumeration<String> parameterNames = request.getParameterNames();
Map<String, String> params = new HashMap<>();
while (parameterNames.hasMoreElements()) {
String paramName = parameterNames.nextElement();
String paramValue = request.getParameter(paramName);
// 忽略空白字符串的参数值
if (paramValue != null && !paramValue.trim().isEmpty()) {
params.put(paramName, paramValue);
}
}
return params;
}
/**
* 获取当前请求的域名,不包含末尾斜杠。
* 首先尝试从Origin头获取,如果不存在则尝试从Referer头获取。
*
* @return 当前请求的域名
*/
public static String getDomain() {
HttpServletRequest request = getHttpServletRequest();
return getDomain(request);
}
/**
* 根据提供的请求对象获取域名,不包含末尾斜杠。
* 首先尝试从Origin头获取,如果不存在则尝试从Referer头获取。
*
* @param request HTTP请求对象
* @return 域名
*/
public static String getDomain(HttpServletRequest request) {
String domain = request.getHeader(HttpHeaders.ORIGIN);
if (domain == null || domain.trim().isEmpty()) {
domain = request.getHeader(HttpHeaders.REFERER);
}
return trimTrailingSlash(domain);
}
/**
* 获取当前请求的Origin头部信息。
*
* @return Origin头部值或null
*/
public static String getOrigin() {
HttpServletRequest request = getHttpServletRequest();
return request.getHeader(HttpHeaders.ORIGIN);
}
/**
* 移除字符串末尾的斜杠(如果有)。
*
* @param str 输入字符串
* @return 处理后的字符串
*/
private static String trimTrailingSlash(String str) {
if (str == null) {
return null;
}
return str.replaceAll("/+$", "");
}
}
redis的前缀工具类 就自己宿便写一个
既然自定义的过滤器写好,过滤出用户信息加入security的上下文完成放行,下一步就行加入配置进入security的过滤器链了
配置文件
修改点 把自定义的过滤逻辑加入过滤器链,然后放行登录注册等名单接口,基于之前案列的配置文件修改
@Configuration
@AllArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {
private final AuthenticationTokenFilter authenticationTokenFilter;
/**
* 加密算法
* @return
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
/**
* security configuration
* @param http
* @return
* @throws Exception
*/
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, AuthenticationSuccessHandler myAuthenSuccessHandler, AuthenticationFailureHandler myAuthenticationFailureHandler) throws Exception {
// 也可以把无需security拦截的接口采用动态数组·方式放行,如:
String[] whitePathList = {"/auth/login", "/auth/register", "/v2/api-docs", "/swagger-resources/**", "/swagger-ui.html", "/webjars/**", "/configuration/**", "/swagger-ui/**"};
http
.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)//加入过滤器链
.csrf(AbstractHttpConfigurer::disable) // 使用最新的Lambda风格禁用CSRF保护
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/auth/login").permitAll()//放行的接口 无需认证的接口登录
.requestMatchers("/auth/register").permitAll()//放行的接口 无需认证的接口登录
.requestMatchers(whitePathList).permitAll()//放行的接口 无需认证的接口登录
.requestMatchers(HttpMethod.OPTIONS).permitAll()
.anyRequest().authenticated() // 要求所有访问都必须通过认证
)
.exceptionHandling(exception -> exception.authenticationEntryPoint(new SecurityAuthenticationEntryPoint()))
// 如果你仍然需要支持基于表单的登录,可以保留这部分配置,并确保处理器返回JSON
// .formLogin(form -> form
// .successHandler(myAuthenSuccessHandler) // 设置自定义认证成功处理器
// .failureHandler(myAuthenticationFailureHandler)
// )
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS) // 不创建会话
);
return http.build();
}
}
此时Springsecurity拦截的规则逻辑都已经处理好了
实现登录逻辑
以前的登录还需要自己完成盐和hash的加密在和密文对比,以及明文加密等过程,使用Springsecuerty以后,就可以采用Springsecurity的登录逻辑,流程和之前写的基于数据库登录例子一样,主要也是依靠实现UserDetailsService的loadUserByUsername方法进行认证
优化之前的DBUserDetailsManager
之前实现了俩个类,对security的规范 用户进行了管理,这里实现比对接口即可
@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private final UserMapper userDao;
private final RoleMapper roleDao;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User userEntity = userDao.selectOne(Wrappers.<User>lambdaQuery().eq(User::getUsername, username));
if (userEntity == null) {
throw new UsernameNotFoundException("用户名或密码错误");
}
return new SecurityUser(userEntity,roleDao);
}
}
认证逻辑过滤器改写好了 下一步也是加入过滤器链
Security配置
/**
* 配置提供登录对比逻辑的类。
* 这里是配置Spring Security认证管理部分的核心逻辑,
* 包括认证提供者和认证管理者。此文件与安全拦截过滤配置分开,
* 以便于后续扩展,例如添加更多的认证方式或自定义认证逻辑。
*/
@Configuration
@AllArgsConstructor
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final UserDetailsService userDetailsService; // 用户详情服务,用于加载用户信息
private final PasswordEncoder passwordEncoder; // 密码编码器,已在之前的配置中注入
private final ApplicationEventPublisher applicationEventPublisher; // 应用事件发布器,用于发布认证事件
/**
* 创建并配置DaoAuthenticationProvider实例。
* DaoAuthenticationProvider是Spring Security默认提供的一个认证提供者,
* 它使用用户名/密码进行认证,并依赖UserDetailsService来加载用户详细信息。
*
* @return DaoAuthenticationProvider 实例
*/
@Bean
DaoAuthenticationProvider daoAuthenticationProvider() {
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder); // 设置密码编码器
daoAuthenticationProvider.setUserDetailsService(userDetailsService); // 设置用户详情服务
return daoAuthenticationProvider;
}
/**
* 创建并配置AuthenticationManager实例。
* AuthenticationManager负责协调多个认证提供者(AuthenticationProvider)。
* 在这里,我们使用ProviderManager作为AuthenticationManager的实现,
* 并将之前创建的DaoAuthenticationProvider加入到providerList中。
* 同时设置了认证事件发布器,用于在认证过程中触发相关事件。
*
* @return AuthenticationManager 实例
*/
@Bean
public AuthenticationManager authenticationManager() {
List<AuthenticationProvider> providerList = new ArrayList<>();
providerList.add(daoAuthenticationProvider()); // 添加DaoAuthenticationProvider
ProviderManager providerManager = new ProviderManager(providerList);
providerManager.setAuthenticationEventPublisher(new DefaultAuthenticationEventPublisher(applicationEventPublisher)); // 设置认证事件发布器
return providerManager;
}
}
ProviderManager:自定义事件发布器。然而,在这个情况下,可能是由于Spring Security的默认配置,它仍然会发布认证成功和失败的事件,即使你并没有显式地配置这个事件。
所以这里添加一个认证成功和失败的处理事件(一般用于记录日志)
@Component
@AllArgsConstructor
@Slf4j
public class AuthenticationEvents {
@EventListener
public void onSuccess(AuthenticationSuccessEvent event) {
// 用户信息 based on Spring Security
SecurityUser user = (SecurityUser) event.getAuthentication().getPrincipal();
log.info("用户 {} 登录成功", user.getUsername());
}
@EventListener
public void onFailure(AbstractAuthenticationFailureEvent event) {
// 用户名
String username = (String) event.getAuthentication().getPrincipal();
log.info("用户 {} 登录失败", username);
}
}
系统未认证用户访问异常处理器
就采用之前案列的那个
/**
* 匿名用户(token不存在、错误),异常处理器
*
*/
public class SecurityAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException {
response.setContentType("application/json; charset=utf-8");
response.setHeader("Access-Control-Allow-Credentials", "true");
response.setHeader("Access-Control-Allow-Origin", HttpContextUtils.getOrigin());
response.getWriter().print(Json.toJsonString(R.error(ErrorCode.UNAUTHORIZED)));
}
}
是一样的效果
准备工作完成,现在些登录接口
登录接口
@RequestMapping("auth")
@RestController
@AllArgsConstructor
@Slf4j
public class AuthenticateController {
@Autowired
RedisCacheService redisCache;
/**
* 认证管理器 就是配置中添加多个登录逻辑提供者(provider)的地方
*/
private final AuthenticationManager authenticationManager;
@PostMapping("login")
public R login(@RequestBody User uservo) throws ServerException {
log.info("接收的参数{}",uservo);
Authentication authentication;
try {
// 用户认证
authentication = authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(uservo.getUsername(), uservo.getPassword()));
} catch (BadCredentialsException e) {
return R.error("账户或密码错误");
}
//security 过滤器链认证成功后会把用户信息放入SecurityContextHolder中 然后可以通过SecurityContextHolder.getContext().getAuthentication().getPrincipal()获取用户信息
log.info("认证通过的信息{}", authentication.getPrincipal());
// 用户信息
SecurityUser securityUser = (SecurityUser) authentication.getPrincipal();
// 生成 accessToken
String token= AppJwtUtil.getToken(String.valueOf(securityUser.getSystemUser().getId()));
String jsonString = JSON.toJSONString(securityUser);
//保存到redis 前缀(设置为常量 这里演示 实际应该根据项目情况设置)加用户名
redisCache.set(LOGIN_USER+securityUser.getSystemUser().getId() , jsonString, 8*60*60);
return R.ok(token);
}
}
自此jwt redis 实现的前后端用户认证效果完成启动项目
便于测试记得添加俩个接口
以及测试用户的一个接口
访问任意接口:
访问白名单接口:
先注册
登录接口获取token
此时携带请求头在访问最开始被拦截的接口
总结
看似做了这么多 其实只有几件事
你的总结已经覆盖了Spring Security认证流程的主要步骤,但为了更加全面和准确,我们可以做一些补充和完善。以下是基于你提供的内容进行扩展后的Spring Security认证使用流程:
认证使用流程总结
-
实现UserDetailsService接口:创建一个服务类实现
UserDetailsService
接口,并重写loadUserByUsername(String username)
方法来加载用户特定数据。这个服务类会由Spring Security的认证管理器(AuthenticationManager
)调用,以验证提供的用户名和密码是否正确。 -
配置认证管理器:在Spring Security的配置中,将自定义的
UserDetailsService
注入到AuthenticationManager
中。这一步确保了当认证请求到达时,Spring Security知道去哪里获取用户信息来进行验证。 -
登录接口处理:
- 前端发送包含用户名和密码的登录请求。
- 在后端,使用
UsernamePasswordAuthenticationToken
封装这些凭据,并通过authenticationManager.authenticate(...)
方法尝试认证。 - 如果认证成功,生成JWT或其他形式的token,并将用户的详细信息(如权限等)存储在诸如Redis这样的缓存系统中。同时,返回token给前端作为后续请求的身份验证凭证。
-
携带Token的请求处理:
- 对于需要认证的每个请求,前端需在HTTP请求头中携带之前获得的token。
- 实现并注册一个自定义过滤器,用于拦截所有进入的请求。该过滤器负责从请求头中提取token,并根据token从Redis(或任何其他存储机制)中检索对应的用户信息。
- 检查用户是否存在且未过期。如果一切正常,将用户信息封装进一个新的
Authentication
对象,并将其设置到SecurityContextHolder
中,以便在整个请求处理过程中随时可用。
-
登出处理:提供登出功能,使用户能够主动清除其session/token,并从缓存中移除相应的用户信息,保证安全性。
关于上下文
其中SecurityContext和一般手动实现的ThreadLocal一样也是作为上下文对象的,并且框架自动实现了拦截器,请求过后删除,无需手动实现下面是gpt回答
Spring Security 内部确实使用了 ThreadLocal 来维护 SecurityContext,实现线程安全的上下文管理。每个线程都有自己的 SecurityContext 实例,确保在处理请求时每个线程的安全上下文是独立的。
关键点
ThreadLocal: SecurityContextHolder 使用 ThreadLocal 来存储当前线程的 SecurityContext,这样每个线程都有自己独立的上下文。
自动管理: Spring Security 自动处理上下文的创建和清理。请求开始时,过滤器链会设置 SecurityContext,请求结束后,Spring Security 会自动清理上下文。通常,SecurityContext 在过滤器链中由 SecurityContextPersistenceFilter 处理。
示例
在 Spring Security 中,通常不需要手动管理 SecurityContext 的生命周期,因为框架已经处理了这个部分。你可以在 Controller 或 Service 层直接使用 SecurityContextHolder 来访问当前的 SecurityContext:
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
@RestController
public class MyController {
@GetMapping("/user-info")
public ResponseEntity<String> getUserInfo() {
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName(); // 获取当前用户的用户名
return ResponseEntity.ok("User: " + username);
}
}
清理
在正常情况下,Spring Security 会在请求处理结束后自动清理 SecurityContext
那么同理登出就是删除reids的数据,并且可以添加登出监听,一样的逻辑不做复述
小细节
这里过滤器的操作都是全放行,但是之前自己实现判断携带token才可以访问受限资源是需要自己根据条件判断是否放行的·
@Component
@AllArgsConstructor
public class AuthenticationTokenFilter extends OncePerRequestFilter {
private static final Logger logger = LoggerFactory.getLogger(AuthenticationTokenFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
// Log the request URI
logger.debug("Processing request: {}", request.getRequestURI());
String accessToken = TokenUtils.getAccessToken(request);
logger.debug("Access token: {}", accessToken);
// accessToken为空,表示未登录
if (StringUtils.isBlank(accessToken)) {
//放行 security的过滤器链配置了拦截的地方权限认证的
chain.doFilter(request, response);
return;
}
// accessToken不为空,表示已登录 校验是否过期
int i = AppJwtUtil.verifyToken(AppJwtUtil.getClaimsBody(accessToken));
if (1==i|| 2== i){
// 过期
OnlineUserManager.removeUser(accessToken);
chain.doFilter(request, response);
return;
}
// 获取登录用户信息
// 用户不存在
if (!OnlineUserManager.containsUser(accessToken)){
chain.doFilter(request, response);
return;
}
SysUser user = OnlineUserManager.getUser(accessToken);
Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
chain.doFilter(request, response);
logger.debug("Completed processing request: {}", request.getRequestURI());
}
}
主要的原因是
.anyRequest().authenticated()
对于这些资源受限的接口.Security会判断上下文对象中是否包含Authentication 因为上下文对象本质是维护了Threadlocal线程隔离的,所以就可以对每个请求进行判断上下文中是否通过认证,所以对于所有请求大可以放心,后需的security过滤器链会判断资源是由可以访问
(自己可以注释下面代码在进行携带token 看看是不是认证就失效了)
SysUser user = OnlineUserManager.getUser(accessToken);
Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
GPT解释:
Spring Security的核心机制依赖于SecurityContextHolder中的Authentication对象来决定是否允许访问某个受保护的资源。
Spring Security 的默认行为
Spring Security 的默认行为是,如果一个请求需要认证(例如在你的配置中,所有未明确permitAll()的请求),它会检查SecurityContextHolder中是否存在一个有效的Authentication对象。如果没有,Spring Security会拒绝请求,返回一个未授权的响应(通常是HTTP 401错误)。
关键逻辑解释
在你的代码中,以下这段代码是关键部分:
SysUser user = OnlineUserManager.getUser(accessToken);
Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(authentication);
SecurityContextHolder.setContext(context);
这个逻辑做了以下事情:
获取用户信息:OnlineUserManager.getUser(accessToken) 获取与accessToken关联的用户信息。
创建Authentication对象:UsernamePasswordAuthenticationToken 用来封装用户的认证信息(包括用户身份和权限)。
设置SecurityContext:SecurityContextHolder 中保存着当前请求的安全上下文。通过context.setAuthentication(authentication),你将用户的认证信息存入了上下文中。
SecurityContextHolder生效:当SecurityContextHolder持有一个有效的Authentication对象时,Spring Security会认为这个请求是经过认证的,可以访问那些需要认证的接口。
不设置SecurityContextHolder 的效果
如果你不执行上述代码(即不设置SecurityContextHolder中的Authentication),那么即使请求被你的过滤器放行,Spring Security仍然会认为这个请求是未认证的。最终,Spring Security会拒绝这个请求访问那些需要认证的接口。
security的认证流程
虽然上面的文章已经详细说明了,这里做总结
这个流程会帮助你更好地理解Spring Security是如何管理认证和授权的。
- 请求到达
当用户尝试访问受保护的资源(例如,需要登录才能访问的页面或API),请求会首先经过Spring Security的过滤器链。- UsernamePasswordAuthenticationFilter 处理登录请求
对于表单登录(Form Login),Spring Security默认使用UsernamePasswordAuthenticationFilter来处理登录请求。这个过滤器会拦截登录请求(通常是/login路径),并提取用户名和密码。- 认证管理器 (AuthenticationManager) 处理认证
UsernamePasswordAuthenticationFilter会将提取到的用户名和密码封装成一个UsernamePasswordAuthenticationToken对象,并将其提交给AuthenticationManager进行认证。
AuthenticationManager通常会委托给一个或多个AuthenticationProvider来执行具体的认证逻辑。
一个常见的AuthenticationProvider是DaoAuthenticationProvider,它通过加载用户的详细信息(如用户名、密码和权限)来进行认证。- UserDetailsService 加载用户信息
DaoAuthenticationProvider会使用UserDetailsService加载用户信息。UserDetailsService是一个接口,它的实现类会从数据库或其他持久化存储中加载用户的信息(如用户名、密码、权限等)。
如果用户名和密码匹配,AuthenticationProvider会创建一个已认证的Authentication对象,并将其返回给AuthenticationManager。- 认证成功或失败
认证成功:认证成功后,AuthenticationManager会将认证信息(包括用户的身份和权限)保存到SecurityContextHolder中,供后续的安全检查使用。
认证失败:如果认证失败,Spring Security会返回一个认证失败的响应(通常是HTTP 401错误)。
l>6. 设置SecurityContextHolder
如果认证成功,Spring Security会将用户的认证信息放入SecurityContextHolder中。SecurityContextHolder是一个持有安全上下文的全局对象,它保存了当前请求的安全信息。
这一步是关键,所有后续的请求处理都会依赖于SecurityContextHolder中的Authentication对象。- 请求放行
如果认证成功,Spring Security会允许请求继续处理,并最终访问受保护的资源。- 访问受保护的资源
在访问受保护资源时,Spring Security会检查SecurityContextHolder中的Authentication对象,以确定用户是否经过认证以及是否有足够的权限访问请求的资源。
如果用户有足够的权限,Spring Security会允许访问。
如果用户没有足够的权限或未认证,Spring Security会拒绝访问,并返回相应的错误响应。- 后续请求的处理
对于后续的每个请求,Spring Security会再次检查SecurityContextHolder中的Authentication对象。如果用户已认证且持有足够权限,Spring Security会允许访问受保护的资源。
额外说明:自定义过滤器的作用
当你创建一个自定义过滤器(例如你提到的AuthenticationTokenFilter),并将其添加到过滤器链中时,你可以控制是否将认证信息放入SecurityContextHolder中。如果你选择不设置SecurityContextHolder,那么即使请求通过了过滤器,Spring Security仍然会认为请求未认证,从而拒绝访问需要认证的资源。
总结
登录认证过程主要包括:拦截登录请求、验证用户名和密码、将认证信息存入SecurityContextHolder。
自定义过滤器的作用:可以在过滤器中对请求进行检查,并决定是否将认证信息放入SecurityContextHolder,进而影响请求是否能够访问受保护的资源。