Spring Security入门到实践

一、快速入门:

        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,"退出成功");
    }
}

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值