资料来自:b站up主: 三更草堂
学习是为了配合up主的springboot项目,里面用到了这个技术,所以配合学习;需要资料的可以去找up要;
自己就是为了记录一下,省略了准备工作,方便回忆(不然滑过准备工作有点累~)
目录
一、什么是SpringSecurity
Spring Security 是 Spring 家族中的一个安全管理框架。相比与另外一个安全框架Shiro,它提供了更丰富的功能,社区资源也比Shiro丰富。
一般来说中大型的项目都是使用SpringSecurity 来做安全框架。小项目有Shiro的比较多,因为相比与SpringSecurity,Shiro的上手更加的简单。
一般Web应用的需要进行认证和授权。
认证:验证当前访问系统的是不是本系统的用户,并且要确认具体是哪个用户
授权:经过认证后判断当前用户是否有权限进行某个操作
而认证和授权也是SpringSecurity作为安全框架的核心功能。
二、快速入门
springBoot整合SpringSecurity非常容易,只需要导入相应的依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
导入后启动项目发送请求,首先进入默认的登录页:
默认用户名user, 在启动的时候会在控制台显示密码,这也就下面认证中默认从内存中进行验证的原因。
登录校验流程
三、认证
3.1 认证流程
从上图中可以分析到:用户提交数据后首先经过UserNamePasswordxxxFilter进行身份验证,经过两个类间的函数调用到:ProviderManger, DaoxxxProvider
在UserDeatilsService接口实现类中的 loadUserByUsername, 参数为用户输入的username, UserDeatilsService的默认实现类在内存中取得数据,然后封装成UserDetails对象进行返回
从上面的流程中有两个问题:怎么通过jwt生成token, 不能从内存中取得数据
因此如果想要定制security, 可以在UserNamePasswordxxxFilter, 以及UserDeatilsService 自己实现从数据库中获取数据
3.2 代码实现
UserDetailsServiceImpl
/**
* 在用户登录的逻辑中,用户提交信息后,会经过UserName的过滤器,经过方法调用走到默认的UserDetailsService
* 的实现类中,在内存中获取用户的信息然后封装成UserDetails对象;
* 所以自己重写UserDetailsService实现类,这样在调用的时候就会走自定义实现,然后从数据库中获取数据,封装后返回给调用者
*
* UserDetails是一个接口,需要自定义实现类
*/
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private UserMapper userMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 根据username从数据库中获取数据
LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(User::getUserName, username);
User user = userMapper.selectOne(queryWrapper);
/*
封装UserDetails类型对象, LoginUserDetails是自定义的具体实现类
UserDeatils中的getPassword, getUserName是自动调用
*/
UserDetails userDetails = new LoginUserDetails(user);
return userDetails;
}
}
LoginUserDetails
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginUserDetails 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;
}
}
UserDetails是接口,所以需要具体实现类,目前需要将后面的boolean类型的方法改为true, 不然无法进行登录
此时需要在数据库中密码字段前面添加:{noop}, 表示密码是明文,默认有PasswordEncoder校验
3.3 密码加密
前面需要在数据库密码字段前面添加{noop},是因为默认有PasswordEncoder对返回的UserDetails类型对象的数据和用户输入进行比较;而默认的PasswordEncoder要求在密码字段前加上{id}进行核实,很不方便,所以引入BCryptPasswordEncoder进行替换
配置类:
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// Bean用来将对象注入容器
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
}
主要的方法:encode用来加密,matches用来暗文匹配
因此在注册用户时候,由于BCryptPasswordEncoder是注入容器的,所以使用@Autowired获取对象然后调用encode进行加密
测试:
@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Test
public void testPasswordEncoder() {
String s1 = "123456";
String s2 = "123456";
String encode = bCryptPasswordEncoder.encode(s1);
String encode1 = bCryptPasswordEncoder.encode(s2);
System.out.println(encode);
System.out.println(encode1);
}
四、登录
4.1 逻辑分析
- 首先需要自定义登陆接口,然后让SpringSecurity对这个接口放行,让用户访问这个接口的时候不用登录也能访问。即 login请求不应该被拦截
- 自定义登录接口中封装Authentication对象然后跳转ProviderManager完成后续调用
- 在接口中我们通过AuthenticationManager的authenticate方法来进行用户认证,所以需要在SecurityConfig中配置把AuthenticationManager注入容器。
- 认证成功的话要生成一个jwt,放入响应中返回。
- 为了让用户下回请求时能通过jwt识别出具体userid,进而获取用户的其他信息(同时减少数据库的频繁访问),我们需要把用户信息存入redis,可以把用户id作为key。
4.2 代码实现
自定义登陆接口
@RestController
public class LoginController {
@Autowired
private LoginService loginService;
// 自定义登录控制器,在业务逻辑层中调用ProviderManager,模拟默认的实现流程
@PostMapping("/user/login")
public ResponseResult login(@RequestBody User user) {
return loginService.login(user);
}
}
让SpringSecurity对这个接口放行
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 配置用于放行login页面
@Override
protected void configure(HttpSecurity http) throws Exception {
//关闭csrf
http.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
}
把AuthenticationManager注入容器。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
// 代码省略
// 将AuthenticationManager作为对象注入容器
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
AuthenticationManager的authenticate方法来进行用户认证
/*
将用户信息封装Authentication对象,然后调用authenticate方法
到ProviderManager,由他完成后去的调用工作
UsernamePasswordAuthenticationToken: Authentication的实现类
*/
Authentication authentication = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authentication);
从返回的验证对象中获取userid
// 如果成功将userid使用jwt生成token返回前端
// principal封装了验证后的用户信息
LoginUserDetails loginUser = (LoginUserDetails) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
用户信息存入redis
// 将user信息存入redis中
redisCache.setCacheObject("login:"+userId,loginUser);
认证成功的话要生成一个jwt,放入响应中返回。
Map<String, String> map = new HashMap<>();
map.put("token", jwt);
return new ResponseResult(200, "验证成功", map);
LoginService业务逻辑层的完整代码:
/**
* 登录验证的业务逻辑层
*
* 默认的流程:在经过过滤器后,会将用户的信息封装成:Authentication
* 然后调用ProviderManager等最后到UserDetailsService,在从数据库
* 获取数据后封装成UserDetails, 使用PasswordEncoder验证后返回
* Authentication对象,如果非空,表示验证成功
*/
@Service
public class LoginServiceImpl implements LoginService {
/**
* 使用redis的原因:
* redis是菲关系型数据库,一般作为缓存的存在;
* 在经过验证后,返回给前端token, 在下一次前端发送的请求中会携带
* token数据,传统情况,在将token解析后需要使用userid查询数据库;
* 那么多次请求的数据库访问太频繁,所以使用redis作为中间缓存的对象。
*/
@Autowired
private AuthenticationManager authenticationManager;
// RedisCache 工具类上面使用了@Component注入了容器
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
/*
将用户信息封装Authentication对象,然后调用authenticate方法
到ProviderManager,由他完成后去的调用工作
UsernamePasswordAuthenticationToken: Authentication的实现类
*/
Authentication authentication = new UsernamePasswordAuthenticationToken(user.getUserName(), user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authentication);
// 是否验证成功
if (Objects.isNull(authenticate)) {
throw new RuntimeException("验证错误");
}
// 如果成功将userid使用jwt生成token返回前端
// principal封装了验证后的用户信息
LoginUserDetails loginUser = (LoginUserDetails) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
// 将user信息存入redis中
redisCache.setCacheObject("login:"+userId,loginUser);
// token响应给前端
Map<String, String> map = new HashMap<>();
map.put("token", jwt);
return new ResponseResult(200, "验证成功", map);
}
}
测试:
五、退出
5.1 逻辑分析
前提:用户已经登录,在redis中可以获取用户信息
实现: 前面定义了 JwtAuthenticationTokenFilter 过滤器,发送logout请求经过过滤器后将当前用户信息存储在 SecurityContextHolder 中;logout的业务逻辑层从 SecurityContextHolder中获取用户信息,得到userid在redis中进行删除
5.2 代码实现
@Service
public class LoginServiceImpl implements LoginService {
/*
登录后,首先通过JwtFilter认证将信息存储在SecurityContextHolder中,同类请求中的
SecurityContextHolder是相同的,所以可以获取用户的id
*/
@Override
public ResponseResult logout() {
// 从SecurityContextHolder获取userid
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUserDetails loginUser = (LoginUserDetails) authentication.getPrincipal();
// 一定可以获取user信息,因为首先经过jwtFilter, 如果没有user会直接抛出异常
Long userid = loginUser.getUser().getId();
// 从redis中删除信息
redisCache.deleteObject("login:" + userid);
return new ResponseResult(200, "退出登录");
}
}
六、授权
权限的作用:不同的用户可以使用不同的功能
6.1 逻辑分析
在之前代码实现中预留的 TODO, 表示授权没有实现;
在UserDeatilsServiceImpl从数据库中获取用户名, 密码以及权限信息;封装信息在LoginUserDetals中。
在JwtAuthenticationTokenFilter过滤时,UsernamePasswordAuthenticationToken(user, null, null); 第三个参数就是权限列表;然后在SpringSecurity中,会使用默认的FilterSecurityInterceptor来进行权限校验。在FilterSecurityInterceptor中会从SecurityContextHolder获取其中的Authentication,然后获取其中的权限信息。当前用户是否拥有访问当前资源所需的权限。
6.2 开启权限
配置类:
@Configuration
// 开启授权
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
请求方法:
@RestController
public class MyController {
// 赋予权限
@PreAuthorize("hasAuthority('test123')")
@RequestMapping("/hello")
public String sayHello() {
return "hello";
}
}
6.3 基础版:权限写死
UserDetailsServiceImpl
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 省略
// TODO: 查询用户的权限信息
List<String> perssions = new ArrayList<>(Arrays.asList("test", "admin"));
UserDetails userDetails = new LoginUserDetails(user, perssions);
return userDetails;
}
}
LoginUserDetails
@Data
@NoArgsConstructor
public class LoginUserDetails implements UserDetails {
private User user;
// 存储权限信息
private List<String> permissions;
// 记录转换后的权限信息;但是 GrantedAuthority 在reids中不能被序列化,所以需要忽略
@JSONField(serialize = false)
private List<GrantedAuthority> authorities;
public LoginUserDetails(User user, List<String> permissions) {
this.user = user;
this.permissions = permissions;
}
// 在过滤器链中会调用这个方法返回权限信息
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
// authorities信息如果不进行存储,每次都会重新封装,可以使用成员变量进行存储
// 权限信息一定在用户登录的时候已经获取了
if (!Objects.isNull(authorities)) {
return authorities;
}
// 将perssions转换成GrantedAuthority类型的集合
List<SimpleGrantedAuthority> authorities = permissions.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return authorities;
}
// 省略
}
JwtAuthenticationTokenFilter
修改下面方法中的第三个参数
// TODO:用户的认证
// 认证:获取权限后封装在LoginUserDetails中,可以调用getxx方法获取
Authentication authentication = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);