springSecurity2的自定义账号密码认证流程+多个参数的自定义流程
声明,本文章是学习 b站三更草堂的视频的时候所写
依赖
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!--security 的依赖...........开始-->
<dependency> <!--引入依赖之后,重启项目 访问接口就变了 需要登录,用户名是 user 密码看控制台 当然后期都可以修改-->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<!--security 的依赖...........结束-->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.3.1</version>
</dependency>
<!--jwt 的 java依赖 简称 jjwt.......开始-->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!--jwt 的 java依赖 简称 jjwt.......结束-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.25</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
配置
spring:
application:
name: demo
redis:
host: localhost
port: 6379
datasource:
url: jdbc:mysql://localhost:3306/cloud_user?useSSL=false&useUnicode=true&characterEncoding=utf8&characterSetServer=utf8mb4&useTimezone=true&serverTimezone=Hongkong
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
server:
port: 8081
测试项目是否正常
@RestController
@RequestMapping("/security")
public class MyController {
@GetMapping("/hello")
public String sayHello() {
return "hello";
}
}
实体类
这里我使用的是 MyBatisX插件生成的
这里我打算模拟学生试用账号密码登录学生系统,结构如下
这里的 studentName是唯一的
概述
在加上了 security之后,访问任何资源就会被保护起来,只有登录之后才能继续访问。而采用的登陆方式是:账号密码登录
账号是 :user
密码是 :控制台输出的
而整个 security 可以看成是一个大型的过滤器链
一共有 15个过滤器,其中,最重要的过滤器就是校验账号密码的过滤器,也就是第6个: UsernamePasswordAuthenqticationFilter
从这里我们就可以看到 我们输入的账号密码是被5号过滤器校验的,以及登陆页面,登出界面是被6和7号过滤器加载的,13号过滤器是处理security期间的异常,14号过滤器是用来校验权限的。
那么我们重点就来修改 5号过滤器
UsernamePasswordAuthenqticationFilter
我们从使用上基本可以猜出,UsernamePasswordAuthenqticationFilter这个过滤器中做的就是,账号密码的校验,但是其中具体是怎么实现的?
这里只是UsernamePasswordAuthenqticationFilter这一个过滤器所需要经过的流程
但是,重点来了
security是前后端不分离的,使用的是session,和我们现在所使用的 前后端分离所需要的token,是不搭边的。
但是,UsernamePasswordAuthenqticationFilter所用到的 AuthenticationManager+xxxProvider++xxxService 很好用,包括了密码校验,权限的添加。所以!!我们使用 controller来接收学生所输入的账号和密码,直接调用 AuthenticationManager+xxxProvider++xxxService,拿取返回值,有返回值代表 认证成功,然后我们自己去生成token返回给前端
形象一些来说,security中,有很多过滤器,每一个过滤器又会调用很多其他的函数,这些,我们 都不用,我们只看上了 AuthenticationManager+xxxProvider++xxxService这个流程。所以我们只需要使用 AuthenticationManager+xxxProvider++xxxService即可,就是从 整个security中,单独的将 AuthenticationManager+xxxProvider++xxxService这套流程 拽出来,供我们使用,取其精华。
而,AuthenticationManager+xxxProvider++xxxService这一整套流程,入口就是 AuthenticationManager.authencate();所以我们只需要 拿到 AuthenticationManager,然后弄出一个符合入参的对象,直接调用即可
另外,我们应该还发现了,密码是从 控制台拿到的,账号固定是user,和我们的数据库完全没有关系,所以还需要和我们的数据库产生一些联系
这个联系是在 xxxService中实现的
所以:
AuthenticationManager是入口
xxxProvider是密码的校验
xxxService是从数据库中查数据
具体实现
- 自己写一个 controller 来接收 账号密码
@PostMapping("/loginWith2Param")
public Object login(@RequestBody StudentLoginModel model) {
return null;
}
- 我们需要拿到 AuthenticationManager,所以首先是将这个对象注入到容器
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
@Bean /*将 AuthenticationManager注入容器*/
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
然后在需要的地方注入这个对象并调用方法,我是在controller里面注入的(这里也是为了方便理解,不再去调用 xxxService,然后再去写 xxxServiceImpl ,来回跳转看着麻烦)
注入之后,我们就可以使用它了,里面就一个方法
authenticationManager.authenticate();
问题来了,需要的是一个 Authentication对象,但是我们现在只有 账号和密码,所以下一步就是,利用账号和密码封装一个Authentication的对象。
这里我们使用的是 框架自带的 UsernamePasswordAuthenticationToken;
像图片中这样创建出来就可以了;
- 然后 还需要写一个根据username查询数据库的方法
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
/*根据用户名查找用户+权限的赋值*/
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
}
}
发现需要返回的是一个 UserDetails(是个接口)类型对象,没有,怎么办(其实框架是自带了一个User类,但是不好用,所以基本都是自己写)。我们需要自己创建一个,毕竟接口就是用来实现的
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MyUserDetails implements UserDetails {
/*todo-6 实现UserDetails接口,完善里面的方法*/
private Student student; //这就是我们从数据库中查出来的对象
private Collection<? extends GrantedAuthority> authorities; //这个是权限集合
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return student.getPassword();
}
@Override
public String getUsername() {
return student.getStudentName();
}
@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 类型的对象,只需要将这个对象创建出来然后返回即可
@Service
public class MyUserDetailsServiceImpl implements UserDetailsService {
/*根据用户名查找用户+权限的赋值*/
@Autowired
private StudentMapper studentMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
LambdaQueryWrapper<Student> wrapper = new LambdaQueryWrapper<Student>().eq(Student::getStudentName, username);
Student student = studentMapper.selectOne(wrapper);
/*权限--这里其实也是从数据库查出来的 这里做省略 主要是要建立一个符合 rbac 的数据库 麻烦*/
ArrayList<SimpleGrantedAuthority> simpleGrantedAuthorities = new ArrayList<>();
simpleGrantedAuthorities.add(new SimpleGrantedAuthority("super"));
simpleGrantedAuthorities.add(new SimpleGrantedAuthority("user"));
simpleGrantedAuthorities.add(new SimpleGrantedAuthority("bandTestQuery"));//等级考试查询权限
/*封装成UserDetails*/
/*todo-5 这里需要返回 UserDetails类型的对象,但是没有,所以需要新建一个类来实现*/
MyUserDetails myUserDetails = new MyUserDetails(student, simpleGrantedAuthorities);
return myUserDetails;
}
}
- 至此, 我们成功调用 authenticationManager.authenticate 方法,剩下的执行流程就是框架帮我们实现即可,我是这么写的
@PostMapping("/loginWith2Param")
public Object login(@RequestBody StudentLoginModel model) {
UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken =
new UsernamePasswordAuthenticationToken(model.getUsername(),model.getPassword());
/*todo-3 拿到这个对象,就去调用方法,也就是整个认证流程的入口*/
Authentication authenticate = authenticationManager.authenticate(usernamePasswordAuthenticationToken);
//以下的代码只是为了展示 认证成功后 authenticate 有什么,长什么样子
HashMap<String, Object> hashMap = new HashMap<>();
hashMap.put("authenticate.getDetails()", authenticate.getDetails());
hashMap.put("authenticate.getPrincipal()", authenticate.getPrincipal());/*就是自己定义的 UserDetails实现类*/
hashMap.put("authenticate.getCredentials()", authenticate.getCredentials());
hashMap.put("authenticate.getAuthorities()", authenticate.getAuthorities());/*就是权限列表*/
//下面是根据 学生id 生成token 其实token就是一个字符串,可以根据自己的喜好来自定义生成,但是为了安全,所以基本都是采用加密的形式生成的,所以我们生成的token还需要能自己解密出来
MyUserDetails userDetails = (MyUserDetails) authenticate.getPrincipal();
JwtBuilder jwtBuilder = Jwts.builder()
.setId("stem86")//id
.setSubject("studentToken")//主题
.setIssuedAt(new Date(System.currentTimeMillis()))//签发时间
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24))//过期时间
.claim("userId", userDetails.getStudent().getId())//设置一些信息
.signWith(SignatureAlgorithm.HS256, "jiubodou");//加密方式 + 密钥(不能泄露)
/*todo-7 authenticationManager.authenticate结束后,就可以根据学生id生成token */
String token = jwtBuilder.compact();
//下面是解密
Claims jiubodou = Jwts.parser().setSigningKey("jiubodou").parseClaimsJws(token).getBody();
Integer userId = (Integer) jiubodou.get("userId"); //这里的 userId是解密出来的userId,应该和加密之前的userId一样
hashMap.put("token", token);
hashMap.put("userId", userDetails.getStudent().getId());
stringRedisTemplate.opsForValue().set("student:" + userId, JSON.toJSONString(userDetails));//存入redis
return hashMap;
}
- 至此,权限的认证已经全部完成,那么权限认证已经全部完成了,剩下的就是用户带着token来访问需要权限的才能访问的接口了。那么就是说,我们需要从 token 中解析出:这个用户是谁,他有什么权限,每一个请求都需要这么做,所以我们采用 过滤器来做这件事
@Component
public class MyJwtTokenFilter extends OncePerRequestFilter {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Override
/*todo-10 过滤器,所有的请求都会经过过滤器,只不过有些请求会直接放行,就比如 /login 这里需要解析token并封装对象,
不做这一步的话,那么请求接口的人是谁,登陆没有?有哪些权限都不知道*/
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
/*解析token,当然这里要和前端商量好,token需要放在 header 中 */
String token = request.getHeader("token");/*例如:token:eyJhbGciOiJIUzI1NiJ9.eyJqdGkiOiJzdGVtODYiLCJzdWIiOiJzdHVkZW50VG9rZW4iLCJpYXQiOjE2ODE5NTk4NDMsImV4cCI6MTY4MTk1OTg0OCwidXNlcklkIjoxfQ.yX7RHqWLsTFgWCjgycsp7NSjPZlXu89bbfm8KcAuIrQ*/
if (StringUtils.isBlank(token)) {
filterChain.doFilter(request, response);
return;
}
Claims jiubodou = Jwts.parser().setSigningKey("jiubodou").parseClaimsJws(token).getBody();
Integer userId = (Integer) jiubodou.get("userId");//解析出是谁
/*根据userId查用户信息*/
String s = stringRedisTemplate.opsForValue().get("student:" + userId);
UserDetails userDetails = JSON.parseObject(s, UserDetails.class);
if (Objects.isNull(userDetails)) {
filterChain.doFilter(request, response);
return;
}
/*重点,从 redis中拿到用户信息了,将其封装起来*/
Collection<? extends GrantedAuthority> authorities = userDetails.getAuthorities();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
//设置到 security 的上下文,因为:这个用户有哪些权限我们这里是设置好了,但是访问接口的时候,权限得到对比我们不做,是由 security来做的,那么也就是说 security是需要某个对象的,拿到这个对象所拥有的权限和接口需要的权限做对比。而这个对象就是 authentication ,所以我们需要将这个对象设置到 security的上下文
SecurityContextHolder.getContext().setAuthentication(authentication);
filterChain.doFilter(request, response);
}
}
- 来访问接口试试
@GetMapping("/getStudent")/*todo-9 成功登录之后,来访问需要权限的接口*/
@PreAuthorize(" @ms.check('bandTestQuery') ") /*todo-11 有了过滤器之后,才能够直接拿到访问的人是谁 有没有权限*/
public HashMap<String, Object> getStudent() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
Object principal = authentication.getPrincipal();
MyUserDetails myUserDetails = JSONObject.parseObject(principal.toString(), MyUserDetails.class);
Student student = myUserDetails.getStudent();
HashMap<String, Object> result = new HashMap<>();
result.put("student", student);
result.put("message", "需要权限的资源访问成功");
return result;
}
- 看到 @PreAuthorize(" @ms.check(‘bandTestQuery’) ") 这里是我自定义 检测方法 可以使用框架自带的 hasAuthority 也是完全可以的
总结一下思路
下面说一下多个参数的验证
N个字段来确定一个用户,但是密码只有一个字段
之前我们都是基于 账号+密码 进行验证的,实际上大部分时间也都是如此,但是有的时候业务需求原因,会有多个参数进行验证
但是对于我的理解而言,不论是几个参数的验证,都可以转换成 账号+密码的模式
username并不是数据库的某个字段,而是理解成 能够唯一确定某个用户所需要的参数
比如,现在有三个参数:
学生手机号 studentMobile
教师手机号 teacherMobile
密码 password
在系统中,学生的手机号+教师手机号 可以唯一确定一条数据,密码依旧是密码
那么 username其实就可以拼接成 studentMobile:teacherMobile
这样其实还是可以使用 usernamePasswordAuthenticationToken 的,只不过我们在查询数据库的时候,执行下面的代码:
String[] split = username.split(":");
String studentMobile = split[0];
String teacherMobile = split[1];
这样就可以将这两条数据拆出来,然后根据数据库怎么查,这个就根据自己公司的业务了
所以,这种解决方案适合: N个字段来确定一个用户,但是密码只有一个字段
N个字段来确定一个用户,密码也有N个字段
我们先来看一下之前的流程图
第7步 可以看到 密码的对比是在 DaoAuthenticationProvider 来对比的,我们找到这段代码来看看
可以看到,只对比了密码,所以说,如果是通过N个字段来判断密码是否正确,根据之前的思路需要怎么弄呢?
例如:现在密码需要两个字段来判断
密码: password
验证码: code
那么还是在生成 authentication 的时候,传入的值是 : password+code
而且还需要修改这里
看起来很麻烦。这种方式我自己也没有尝试过,但是理论上是可以的
更为通用的方法,重写 xxxProvider
还是看一下流程图
authenticationManager.authenticate方法是入口,provider来对比密码,service是查看数据库
同样的,首先是写一个 三个参数的 入参,查数据库的service,也重新写一个
入参如下:
public class LoginWith3ParamToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = 560L;
private final Object principal;
private Object credentials;
private Object teacherUuid;//加一个参数
public LoginWith3ParamToken(Object principal, Object credentials, Object teacherUuid) {
super((Collection)null);
this.principal = principal;
this.credentials = credentials;
this.teacherUuid = teacherUuid;//加一个参数
this.setAuthenticated(false);
}
public LoginWith3ParamToken(Object principal, Object credentials, Object teacherUuid, Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.credentials = credentials;
this.teacherUuid = teacherUuid;//加一个参数
super.setAuthenticated(true);
}
public Object getCredentials() {
return this.credentials;
}
public Object getPrincipal() {
return this.principal;
}
public Object getTeacherUuid() {//加一个参数
return this.teacherUuid;
}
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
Assert.isTrue(!isAuthenticated, "Cannot set this token to trusted - use constructor which takes a GrantedAuthority list instead");
super.setAuthenticated(false);
}
public void eraseCredentials() {
super.eraseCredentials();
this.credentials = null;
}
}
service如下:
@Data
@AllArgsConstructor
@NoArgsConstructor
public class LoginWith3ParamUserDetails implements UserDetails {
private Student student;
private Collection<? extends GrantedAuthority> authorities;
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
return authorities;
}
@Override
public String getPassword() {
return student.getPassword();
}
@Override
public String getUsername() {
return student.getStudentName();
}
@Override
public boolean isAccountNonExpired() {
return true;
}
@Override
public boolean isAccountNonLocked() {
return true;
}
@Override
public boolean isCredentialsNonExpired() {
return true;
}
@Override
public boolean isEnabled() {
return true;
}
}
调用入口的时候,也用三参的,
@PostMapping("/loginWith3Param")
public String loginWith3Param(@RequestBody LoginWith3ParaModel model) {
Authentication loginWith3ParamToken = new LoginWith3ParamToken(model.getUsername(), model.getPassword(),
model.getTeacherUuid());//使用三个参数的
Authentication authenticate = authenticationManager.authenticate(loginWith3ParamToken);
return JSON.toJSONString(authenticate);
}
那么既然要对比密码,也就是重写 provider,那怎么重写?我不会,但是框架会,我们找到 usernamePassword的provider
发现
继承了 AbstractUserDetailsAuthenticationProvider,而
AbstractUserDetailsAuthenticationProvider 实现了 AuthenticationProvider
我们看一下 AuthenticationProvider的实现类
这两个就是我们刚才说的两个类
那么我们就把这两个类的代码复制一份,
- 把AbstractUserDetailsAuthenticationProvider里面的 UsernamePasswordAuthenticationToken 全部改成自己的 LoginWith3ParamToken
- 把 DaoAuthenticationProvider 里面的 UsernamePasswordAuthenticationToken 也都改成LoginWith3ParamToken,然后找到additionalAuthenticationChecks这个方法,我们来看一眼这个方法
if (authentication.getCredentials() == null) {
this.logger.debug("Failed to authenticate since no credentials provided");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
} else {
String presentedPassword = authentication.getCredentials().toString(); /*获取密码*/
if (!this.passwordEncoder.matches(presentedPassword, userDetails.getPassword())) { /*对比密码*/
this.logger.debug("Failed to authenticate since password does not match stored value");
throw new BadCredentialsException(this.messages.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials"));
}
}
其中重要的代码就是 对比密码,所以说,我们需要改动的点,就找到了,就在这个方法里面
我是这样改的
LoginWith3ParamUserDetails loginWith3ParamUserDetails = (LoginWith3ParamUserDetails)userDetails;
if (authentication.getCredentials() == null) {
throw new RuntimeException("密码获取不到");
} else {
String presentedPassword = authentication.getCredentials().toString();
String presentedTeacherUuid=authentication.getTeacherUuid().toString();
if (
!this.passwordEncoder.matches(presentedPassword, loginWith3ParamUserDetails.getPassword())
|| !presentedTeacherUuid.equals(loginWith3ParamUserDetails.getStudent().getTeacheruuid())
) {
throw new RuntimeException("密码和教师uuid对比失败");
}
}
ok,到这里其实所有的准备工作都做完了,但是各位有没有想过,东西我们写出来了。怎么给框架管理呢?我们写的东西总要和框架产生一些关联吧。我们是创建了很多新的类,而不是重写了框架中原有的方法,所以最后一步,将我们写的东西托给框架管理
@Configuration
public class MySecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private MyJwtTokenFilter myJwtTokenFilter;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
@Bean /*todo-1 将 AuthenticationManager注入容器*/
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
@Override /*todo-8 这里需要配置一下放行的接口*/
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
.antMatchers("/security/hello").permitAll() /*公共的资源,比如,首页,广告等,所有人都可以访问*/
.antMatchers("/security/loginWith2Param").anonymous()/*登陆页面只允许 匿名访问,就是没登陆的人访问*/
.antMatchers("/security/loginWith3Param").anonymous()/*登陆页面只允许 匿名访问,就是没登陆的人访问*/
.anyRequest().authenticated();
http.addFilterBefore(myJwtTokenFilter, UsernamePasswordAuthenticationFilter.class);
http.cors();/*允许跨域*/
}
@Autowired
private LoginWith3ParamServiceImpl loginWith3ParamService;
@Autowired
private MyUserDetailsServiceImpl myUserDetailsService;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
// super.configure(auth);
LoginWith3ParamDaoAuthenticationProvider loginWith3ParamDaoAuthenticationProvider = new LoginWith3ParamDaoAuthenticationProvider();
loginWith3ParamDaoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
loginWith3ParamDaoAuthenticationProvider.setUserDetailsService(loginWith3ParamService);
auth.authenticationProvider(loginWith3ParamDaoAuthenticationProvider);
/*这个是 框架自带的UsernamePassword里面的 Provider ,因为我们要将自己的 provider添加进去,所以重写了
* configure(AuthenticationManagerBuilder auth)这个方法,虽然可以加在我们自己的provider了,但是
* 带来的结果就是,原本框架自带的一些provider不再自动加载、需要我们自己手动加载*/
DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
daoAuthenticationProvider.setPasswordEncoder(passwordEncoder());
daoAuthenticationProvider.setUserDetailsService(myUserDetailsService);
auth.authenticationProvider(daoAuthenticationProvider);
}
}