SpringSecurity登录逻辑快速集成及原理探查

框架简介

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。**

总体逻辑

SpringSecurity登录逻辑快速集成及原理探查_第1张图片

准备工作

  1. 在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>
  1. 数据库存储密文密码加密方式。
    @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"));
    }
  1. 实体类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;
}
  1. 其他层(Conttroller、Service、ServiceImpl、Mapper)文件请自行建好,并写一个测试接口。
  2. 公共返回类(非必须)
@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的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
    SpringSecurity登录逻辑快速集成及原理探查_第2张图片
  • 图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
    UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责。
    ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
    FilterSecurityInterceptor:负责权限校验的过滤器。

2、认证流程SpringSecurity登录逻辑快速集成及原理探查_第3张图片
概念速查:
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);
    }

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过滤器
                .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
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

思考:

看完整个流程,是不是发现没有校验数据库密码的代码

认证流程源码探查

下面是没有用dubug模式,建议使用dubug模式,可以参考下面的流程跟踪流程。

1、在登录方法里点击校验方法 authenticat()。
SpringSecurity登录逻辑快速集成及原理探查_第4张图片

2、跳转到 AuthenticationManager 接口,接下来进入实现该方法的类 ProviderManager。
SpringSecurity登录逻辑快速集成及原理探查_第5张图片
在这里插入图片描述

3、在 ProviderManager 类的 authenticate() 方法中进入调用的方法 provider.authenticate() 中。
SpringSecurity登录逻辑快速集成及原理探查_第6张图片

4、在 AuthenticationProvider 接口里找到实现 authenticate() 方法的类。
SpringSecurity登录逻辑快速集成及原理探查_第7张图片
SpringSecurity登录逻辑快速集成及原理探查_第8张图片

5、在 AbstractUserDetailsAuthenticationProvider 中进入方法 retrieveUser() 中。
SpringSecurity登录逻辑快速集成及原理探查_第9张图片

6、进入继承抽象类 AbstractUserDetailsAuthenticationProvider 的 DaoAuthenticationProvider 类,在 retrieveUser() 方法中进入方法 loadUserByUsername() 中。
在这里插入图片描述
SpringSecurity登录逻辑快速集成及原理探查_第10张图片

7、进入到了熟悉的接口 UserDetailsService 中,然后找实现该接口的方法。
SpringSecurity登录逻辑快速集成及原理探查_第11张图片
SpringSecurity登录逻辑快速集成及原理探查_第12张图片

8、最终形成一个闭环。
SpringSecurity登录逻辑快速集成及原理探查_第13张图片

注意:那么,检验密码的地方在哪里

SpringSecurity登录逻辑快速集成及原理探查_第14张图片
SpringSecurity登录逻辑快速集成及原理探查_第15张图片

你可能感兴趣的:(Java,java,Spring,Security,登录,token,源码)