一、快速入门:
1、引入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
实际上直接在对应服务商引入该依赖,使用网页再次访问该接口时,就会出现登录页面,用户名跟密码都是默认的。密码在启动服务的日志里面会打印出来。
二、需要实现的逻辑
实现逻辑总结:
第一次访问的时候,用户名、密码传给后端,后端登录接口匹配用户名和密码,如果正确返回给前端一个使用userId生成的jwt,,对应用户信息存入redis中,key是用户的userId.
再次访问时要携带我们生成的jwt,因为jwt是根据userId生成的,所以我们可以根据jwt解析出userId,然后再根据userId去redis中查询,如果有对应userId的信息就放行,没有就认证失败。
所以要显示两个功能一个是登录接口,一个是认证接口。
三、Spring Security中的原理
1)SpringSecurity的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。这里我们可以看看入门案例中的过滤器。
上面是三个最核心的三个过滤器,其中第一个跟第三个是需要我们重写一些东西,来完成我们自己的逻辑的,
1、UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登录请求,入门案例的认证工作都有他的负责。
2、 ExceptionTranslationFilter:处理在认证授权中出现的所有异常,这样子可以做一些统一的处理。
3、FilterSecurinterceptor:主要负责一个授权的功能,他会去判断你当前这个用户是谁,就可以判断你当前访问的资源需要什么权限,你具有什么权限,你是否能够去访问
2)我们可以通过Debug查看当前系统中SpringSecurity过滤器链中有哪些过滤器及它们的顺序。
3)认证流程(UsernamePasswordAuthenticationFilter)中的原理:
简易理解:
1、其实就是前端一开始把用户密码传给UsernamePasswordAuthenticationFilter过滤器中,然后把用户名跟密码封装成一个Authentication对象,
(Authentication接口:它的实现类表示当前访问系统的用户,封装了用户相关信息,比如说用户名跟密码,包括用户的一些权限都可以,但是在这个最简单的情况下只提交的用户名跟密码,他封装的对象里面也只有用户名跟密码信息。 )
2、然后这个Authentication对象经过一层层传递,最后就到了MemoryUserDetailsManager对象当中,
3、到了MemoryUserDetailsManager中之后,会调用loadUserByUsername方法查询用户,根据用户名查询用户的一个方法,在这个方法内部会去根据用户名查询对应的这个用户,以及整个用户对应的权限信息,然后会把这个用户信息封装成一个userDetail对象,在回传给DaoAuthenticationProvider中
4、在Provider中拿到了这个方法的返回值,他会使用PasswordEncoder对比UserDetails中的密码和Authentication的密码是否正确,如果正确就把UserDetails中的权限信息设置到Authentication对象中。然后再把Authentication对象在返回给最初的UsernamePasswordAuthenticationFilter过滤器中,
5、最后在UsernamePasswordAuthenticationFilter过滤器中,如果返回了Authentication对象,他会把这个对象存到SecurityContextHolder.getContext().setAuthentication中,存储起来之后,其他的过滤器会从SecurityContextHolder当中来获取已经认证过的用户信息。
四、思路分析:
1、通过上述的原理我们知道,用户名和密码提交上来之后,最后会到userDetailService中,根据用户名去查询用户信息,默认的是在内存中查询信息的,所以我们现在要改写这个方法,让他去数据库中查,然后返回一个UserDeails对象即可
2、由上可知,fliter中也没有生成token,所以我们要改写这点,解决这点的思路就是,直接写一个登录接口,也就是controller,我们controller当中我们去调用ProvidManager,这个还用原来的,最后调用User Detail Service的时候再替换成我们自己的方法。最后再返回到我们自己的controller当中,然后自己的controller中去进行一个校验,如果是有返回数据的话,说明校验通过了,我就生成一个token响应回去。也就是下图所示:
登录逻辑:
用户名密码进入到过滤器之后,先经过我们自己的登录接口,我们自己的登录接口中调用后面的方法,然后最后返回到我们自己的登录接口中,我们就可以自己做一些操作,生成token等等。
再次登录逻辑:(也就是携带了token的认证)
再次登录时:我们就需要自己去定一个过滤器了,如果登录完之后,前端拿到token,前端再去请求其他接口的时候,就要携带token去发请求,这个时候我们就要先去判断你是否携带token,所以我们可以定义一个基于jwt认证的一个过滤器,在这个过滤器中先获取请求头中的token,然后解析token,然后获取到userId,拿到用户信息之后就可以封装成Authentication对象,再存入SecurityContextHolder,其他的过滤器,包括我们自己定义的接口当中,都可以从这个SecurityContextHolder当中获取当前登录的用户信息。
jwt认证过滤器获取userId之后,我们可以把完整的用户信息存入redis中,然后通过userId获取完整的用户信息,比如一些权限等等,就不用每次都去访问redis了。那什么时候存呢?可以在认证通过生成jwt时,将userId作为key,用户信息作为value存入redis。
也就是说,我们再次登录时,就不是去访问我们的登录接口了,肯定时访问的别的接口,此时是携带token的,这个时候我们就可以自己定义一个过滤器去解析token,解析出userId之后,我们就可以去redis中查询用户信息,封装成Authentication对象,再存入SecurityContextHolder,设置为已认证用户。
3、实现功能:
综上所述,我们需要实现的功能如下:分为登录和校验两部分:
登录:(第一次登陆时)
①自定义登录接口:
1、调用ProviderManager的方法进行认证 如果认证通过生成jwt
2、把用户信息存入redis中
②自定义UserDetailsService
1、在这个实现类中去查询数据库
校验:(第二次访问时,携带token)
①定义Jwt认证过滤器
1、获取token
2、解析token获取其中的userid
3、从redis中获取用户信息,存入SecurityContextHolder
五、实现代码
1、准备
依赖:其他的都略了,生成jwt我使用的是:
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.2.1</version>
</dependency>
实体类:
@Getter
@Setter
@ToString(callSuper = true)
@Accessors(chain = true)
@TableName("sms_user")
public class User extends CommonModel {
private static final long serialVersionUID = 1L;
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
private Long id;
/**
* 用户名
*/
private String userName;
/**
* 昵称
*/
private String nickName;
/**
* 密码
*/
private String password;
/**
* 帐号状态 0正常 1停用
*/
private String status;
/**
* 邮箱
*/
private String email;
/**
* 手机号
*/
private String phonenumber;
/**
* 用户性别
*/
private String sex;
/**
* 头像
*/
private String avatar;
/**
* 用户类型(0管理员 1普通用户)
*/
private String userType;
/**
* 创建人用户id
*/
private Long createBy;
/**
* 更新人
*/
private Long updateBy;
}
数据库表:
CREATE TABLE `sys_user` (
`id` BIGINT(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '用户名',
`nick_name` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '昵称',
`password` VARCHAR(64) NOT NULL DEFAULT 'NULL' COMMENT '密码',
`status` CHAR(1) DEFAULT '0' COMMENT '账号状态(0正常 1停用)',
`email` VARCHAR(64) DEFAULT NULL COMMENT '邮箱',
`phonenumber` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
`sex` CHAR(1) DEFAULT NULL COMMENT '用户性别(0男,1女,2未知)',
`avatar` VARCHAR(128) DEFAULT NULL COMMENT '头像',
`user_type` CHAR(1) NOT NULL DEFAULT '1' COMMENT '用户类型(0管理员,1普通用户)',
`create_by` BIGINT(20) DEFAULT NULL COMMENT '创建人的用户id',
`create_time` DATETIME DEFAULT NULL COMMENT '创建时间',
`update_by` BIGINT(20) DEFAULT NULL COMMENT '更新人',
`update_time` DATETIME DEFAULT NULL COMMENT '更新时间',
`del_flag` INT(11) DEFAULT '0' COMMENT '删除标志(0代表未删除,1代表已删除)',
PRIMARY KEY (`id`)
) ENGINE=INNODB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COMMENT='用户表'
2、首先实现UserDetailsService(上面说过,我们最终会把用户名密码传入这里面,然后根据用户名查询用户信息,不过默认的方法是在内存中查询, 所以要重写这个方法,改为再数据库中查询)
@Service
public class UserDetailServiceImpl implements UserDetailsService {
@Autowired
private IUserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
//查询用户信息
LambdaQueryWrapper<User> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(User::getUserName,username);
User user = userService.getOne(wrapper);
//如果没有抛出异常
if(Objects.isNull(user)){
throw new BizException("用户名或密码错误");
}
//查询用户权限信息
return new LoginUser(user);
}
}
因为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();
}
/**
* 是否过期
* @return
*/
@Override
public boolean isAccountNonExpired() {
return true;
}
/**
* 是否
* @return
*/
@Override
public boolean isAccountNonLocked() {
return true;
}
/**
* 是否没有超时
* @return
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}
/**
* 是否可用
* @return
*/
@Override
public boolean isEnabled() {
return true;
}
}
写到这一步,就可以根据简单案例来测一下数据库中的用户能否访问了。但是要注意,我们的密码一般是加密的,如果储存的是明文密码,需要再密码前加上{noop}这样数据库就知道你存储的是铭文密码了,
3、密码加密存储:
实际项目中我们不会把密码明文存储在数据库中。
默认使用的PasswordEncoder要求数据库中的密码格式为:{id}password 。它会根据id去判断密码的加密方式。但是我们一般不会采用这种方式。所以就需要替换PasswordEncoder。
我们一般使用SpringSecurity为我们提供的BCryptPasswordEncoder。
我们只需要使用把BCryptPasswordEncoder对象注入Spring容器中,SpringSecurity就会使用该PasswordEncoder来进行密码校验。
我们可以定义一个SpringSecurity的配置类,SpringSecurity要求这个配置类要继承WebSecurityConfigurerAdapter。
也就是说我们的配置类只要继承了WebSecurityConfigurerAdapter,然后重写方法,我们的SpringSecurity就会自动调用重写方法。
@Configuration
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter;
//创建BCryptPasswordEncoder注入容器
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
public static void main(String[] args) {
BCryptPasswordEncoder encoder = new BCryptPasswordEncoder();
System.out.println(encoder.encode("123"));
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
4、上面就完成了用户名密码的查询以及校验,接下来就要重写登录接口:
@RestController
public class LoginController {
@Autowired
private LogService logService;
@PostMapping("/user/login")
public CommonResp<String> login(@RequestBody User user){
return CommonResp.success(logService.login(user));
}
}
public interface LogService {
String login(User user);
}
@Service
public class LogServiceImpl implements LogService {
@Autowired
private AuthenticationManager authenticationManager;
@Override
public String login(User user) {
//AuthenticationManager authenticate进行用户认证
//点进方法中,按住alt+ctrl 左键在点参数,就可以选择实现类类型,然后选择UsernamePasswordAuthenticationToken即可
//然后把用户名密码放进去,这样就相当于把用户名密码封装成了Authentication对象
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
//认证方法,需要参数是Authentication类型,需要参数是Authentication类型,
// Authentication类型是接口类型,所以要创建实现类类型放入进来,并要将用户名密码封装成Authentication类型的
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
//如果没通过给出对应提示
if(Objects.isNull(authenticate)){
throw new BizException("登陆失败");
}
//如果认证通过了,使用userID生成一个jwt
//认证之后会getPrincipal中存放的就是User对象,可以获取userID
LoginUser loginUser = (LoginUser)authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
// 创建一个算法实例
Algorithm algorithm = Algorithm.HMAC256(constants.secretKey);
// 创建一个 JWT 构建器
JWTCreator.Builder builder = JWT.create();
builder.withSubject(userId);
// 生成 JWT
String jwt = builder.sign(algorithm);
return jwt;
}
}
但是需要注意的是,因为登录接口,一般是第一次访问,所以没有token,所以我们就需要放行访问登录接口的请求,需要在配置类中重写以下配置,放行
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
}
5、认证过滤器
完成了以上其实也就是完成了登录的功能了,此时从登录接口登录,就会返回token了。
接下来我们需要:
1、我们需要自定义一个过滤器,这个过滤器会去获取请求头中的token,对token进行解析取出其中的userid。
2、使用userid去redis中获取对应的LoginUser对象。
3、然后封装Authentication对象存入SecurityContextHolder
@Component
//OncePerRequestFilter保证一个请求只会经过这个过滤器一次
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter {
@Autowired
private IUserService userService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
//获取token
String token = request.getHeader("token");
if(StrUtil.isBlank(token)){
//放行
//因为后面还会判断你是不是认证状态,如果不是也会拦截,所以放行
filterChain.doFilter(request, response);
return;
}
//解析token
// 解析 JWT
Algorithm algorithm = Algorithm.HMAC256(constants.secretKey);
DecodedJWT decodedJWT = JWT.require(algorithm).build().verify(token);
String userId = decodedJWT.getSubject();
//从redis中获取用户信息
User user = userService.getById(userId);
if(Objects.isNull(user)){
throw new BizException("用户未登录");
}
//存入SecurityContextHolder
//使用三个参数构造函数,会将一个成员变量变成true,也就是设置成已认证的状态
//第三个参数是过去权限信息,现在还没有
UsernamePasswordAuthenticationToken u
= new UsernamePasswordAuthenticationToken(user,null,null);
SecurityContextHolder.getContext().setAuthentication(u);
//放行
filterChain.doFilter(request,response);
}
}
然后需要在刚才放行的配置中,加入这么一段:
不然过滤器没有加入到过滤链中,
//把token校验过滤器添加到过滤器链中 http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
@Override
protected void configure(HttpSecurity http) throws Exception {
http
//关闭csrf
.csrf().disable()
//不通过Session获取SecurityContext
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)
.and()
.authorizeRequests()
// 对于登录接口 允许匿名访问
.antMatchers("/user/login").anonymous()
// 除上面外的所有请求全部需要鉴权认证
.anyRequest().authenticated();
//把token校验过滤器添加到过滤器链中
http.addFilterBefore(jwtAuthenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
}
6、退出登录:
我们只需要定义一个登陆接口,然后获取SecurityContextHolder中的认证信息,删除redis中对应的数据即可。
/**
* @Author 三更 B站: https://space.bilibili.com/663528522
*/
@Service
public class LoginServiceImpl implements LoginServcie {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private RedisCache redisCache;
@Override
public ResponseResult login(User user) {
UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user.getUserName(),user.getPassword());
Authentication authenticate = authenticationManager.authenticate(authenticationToken);
if(Objects.isNull(authenticate)){
throw new RuntimeException("用户名或密码错误");
}
//使用userid生成token
LoginUser loginUser = (LoginUser) authenticate.getPrincipal();
String userId = loginUser.getUser().getId().toString();
String jwt = JwtUtil.createJWT(userId);
//authenticate存入redis
redisCache.setCacheObject("login:"+userId,loginUser);
//把token响应给前端
HashMap<String,String> map = new HashMap<>();
map.put("token",jwt);
return new ResponseResult(200,"登陆成功",map);
}
@Override
public ResponseResult logout() {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
LoginUser loginUser = (LoginUser) authentication.getPrincipal();
Long userid = loginUser.getUser().getId();
redisCache.deleteObject("login:"+userid);
return new ResponseResult(200,"退出成功");
}
}