框架简介
Spring 是非常流行和成功的 Java 应用开发框架,Spring Security 正是 Spring 家族中的成员。Spring Security 基于 Spring 框架,提供了一套 Web 应用安全性的完整解决方案。 一般来说,Web应用的安全性包括用户认证(Authentication)和用户授权 (Authorization)两个部分,这两点也是 Spring Security 重要核心功能。
(1)用户认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
(2)用户授权:经过认证后判断当前用户是否有权限进行某个操作
注:本文基于B站up主“三更草堂”讲解视频进行简化和说明。
- 使用hutool的jwt工具
- 使用Redis自带的redisTemplate
- 去掉过时的接口WebsecurityConfigurerAdapter
- 去掉无关紧要的代码
前置:前后端分离,默认您已建立好最原始的SpringBoot工程,并连通Mysql,Redis。
总体逻辑
准备工作
- 在pom.xml文件中添加如下四个依赖,除第一个核心依赖外其他的都是为了简化开发。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.2</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.22</version>
</dependency>
- 数据库存储密文密码加密方式。
@Autowired
private PasswordEncoder passwordEncoder;
@Test
public void TestBCryptPasswordEncoder(){
String encode = passwordEncoder.encode("1234");
System.out.println(encode);
// 验证,如果匹配则返回True
System.out.println(passwordEncoder.matches("1234",
"$2a$10$npv5JSeFR6/wLz8BBMmSBOMb8byg2eyfK4/vvoBk3RKtTLBhIhcpy"));
}
- 实体类User,只包含了必须字段,注意用户名和密码必须叫username和password。
@Data
@AllArgsConstructor
@NoArgsConstructor
public class User implements Serializable {
private static final long serialVersionUID = 1L;
//用户id
private int id;
//用户名
private String username;
//用户密码
private String password;
}
- 其他层(Conttroller、Service、ServiceImpl、Mapper)文件请自行建好,并写一个测试接口。
- 公共返回类(非必须)
@Data
@AllArgsConstructor
@NoArgsConstructor
public class ResponseResult<T> {
/**
* 状态码
*/
private Integer code;
/**
* 提示信息,如果有错误时,前端可以获取该字段进行提示
*/
private String msg;
/**
* 查询到的结果数据,
*/
private T data;
public ResponseResult(Integer code, String msg) {
this.code = code;
this.msg = msg;
}
}
核心逻辑
1、当引入Spring Security依赖后,尝试去访问接口就会自动跳转到一个SpringSecurity的默认登陆页面,默认用户名是user,密码会输出在控制台。必须登陆之后才能对接口进行访问。
- SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
- 图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责。
ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
FilterSecurityInterceptor:负责权限校验的过滤器。
2、认证流程
概念速查:
Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。
AuthenticationManager接口:定义了认证Authentication的方法
UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。
UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。
3、实现思路
登录
①自定义登录接口,调用ProviderManager的方法进行认证,如果认证通过生成jwt,把用户信息存入redis中
②自定义UserDetailsService,在这个实现类中去查询数据库
校验:
①定义JWT认证过滤器,获取token,解析token获取其中的userId,从redis中获取用户信息存入SecurityContextHolder
实现步骤
1、前面的用户名密码认证是走的 UserDetailsService 中默认的方法,也就是上图的第五步,因此创建一个类实现UserDetailsService 接口,重写 loadUserByUsername 方法,使其从数据库中查询用户信息。
@Service
@AllArgsConstructor
public class UserDetailsServiceImpl implements UserDetailsService {
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUsername, username);
User user = userMapper.selectOne(wrapper);
if (Objects.isNull(user)) {
throw new RuntimeException("用户名不存在");
}
return new LoginUser(user);
}
}
2、因为 UserDetailsService 方法的返回值是 UserDetails 类型,所以需要定义一个类,实现该接口,把用户信息封装在其中。
@Data
@NoArgsConstructor
@AllArgsConstructor
public class LoginUser implements UserDetails {
private User user;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return null;
}
@Override
public String getPassword() {
return user.getPassword();
}
@Override
public String getUsername() {
return user.getUsername();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
3、编写登录接口(只放实现代码,其他层自己写好)
// 登录,user 中包含前端传过来的 username 和 password
@Override
public ResponseResult<Map<String, String>> login(User user) {
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(user.getUsername(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
// 认证成功之后,获取用户id
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = String.valueOf(loginUser.getUser().getId());
// 将用户id存入token的Payload中
Map<String, Object> map = new HashMap<String, Object>() {
private static final long serialVersionUID = 1L;
{
put("userId", userId);
}
};
String token = JWTUtil.createToken(map, "jwt-secret".getBytes());
Map<String, String> resultMap = new HashMap<>();
resultMap.put("token", token);
// 把完整的用户信息存入redis,userId作为key,过期时间为60分钟
redisTemplate.opsForValue().set(userId, loginUser, 60 * 60, TimeUnit.SECONDS);
return new ResponseResult<>(200, "登录成功", resultMap);
}
// 注销登录,小坑:注意注销登录的接口不能直接是 /logout
@Override
public ResponseResult<String> logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser principal = (LoginUser) authentication.getPrincipal();
Integer userId = principal.getUser().getId();
redisTemplate.delete(String.valueOf(userId));
return new ResponseResult<>(200, "退出成功");
}
4、配置放行登录接口(三更中使用的 WebsecurityConfigurerAdapter 在 Spring Security 5.3 版本中已被弃用)
@Configuration
@AllArgsConstructor
public class SecurityConfig {
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
// 下面配置为验证数据库密码时可以存明文,方便测试。
// return NoOpPasswordEncoder.getInstance();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration authenticationConfiguration) throws Exception {
return authenticationConfiguration.getAuthenticationManager();
}
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 关闭csrf
.csrf().disable()
// 前后端分离,不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
// 添加自定义jwt过滤器,配置在UsernamePasswordAuthenticationFilter过滤器前面
.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
return http.build();
}
}
5、自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的 userId。使用 userId 去 redis 中获取对应的 LoginUser 对象。然后封装 Authentication 对象存入 SecurityContextHolder,因为后面的过滤器需要使用该对象。
@Component
@AllArgsConstructor
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
private RedisTemplate redisTemplate;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("Authorization");
if (!StringUtils.hasText(token)) {
// 放行
filterChain.doFilter(request, response);
// 因为后面还有过滤器,防止响应回来代码继续往下执行
return;
}
// 验证token
if (!JWTUtil.verify(token, "jwt-secret".getBytes())) {
throw new RuntimeException("token无效");
}
//解析token
JWT jwt = JWTUtil.parseToken(token);
Object userId = jwt.getPayload("userId");
//从redis中获取用户信息
LoginUser loginUser = (LoginUser) redisTemplate.opsForValue().get(userId);
if (Objects.isNull(loginUser)) {
throw new RuntimeException("用户未登录");
}
// 存入SecurityContextHolder,因为后面的过滤器需要对请求进行认证,可以判断 SecurityContextHolder 里的用户信息
UsernamePasswordAuthenticationToken authenticationToken =
new UsernamePasswordAuthenticationToken(loginUser,null,null);
SecurityContextHolder.getContext().setAuthentication(authenticationToken);
//放行
filterChain.doFilter(request, response);
}
}
思考:
看完整个流程,是不是发现没有校验数据库密码的代码
认证流程源码探查
下面是没有用dubug模式,建议使用dubug模式,可以参考下面的流程跟踪流程。
1、在登录方法里点击校验方法 authenticat()。
2、跳转到 AuthenticationManager 接口,接下来进入实现该方法的类 ProviderManager。
3、在 ProviderManager 类的 authenticate() 方法中进入调用的方法 provider.authenticate() 中。
4、在 AuthenticationProvider 接口里找到实现 authenticate() 方法的类。
5、在 AbstractUserDetailsAuthenticationProvider 中进入方法 retrieveUser() 中。
6、进入继承抽象类 AbstractUserDetailsAuthenticationProvider 的 DaoAuthenticationProvider 类,在 retrieveUser() 方法中进入方法 loadUserByUsername() 中。
7、进入到了熟悉的接口 UserDetailsService 中,然后找实现该接口的方法。
8、最终形成一个闭环。
注意:那么,检验密码的地方在哪里