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。

总体逻辑

在这里插入图片描述

准备工作

  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的原理其实就是一个过滤器链,内部包含了提供各种功能的过滤器。
    在这里插入图片描述
  • 图中只展示了核心过滤器,其它的非核心过滤器并没有在图中展示。
    UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求。入门案例的认证工作主要由它负责。
    ExceptionTranslationFilter:处理过滤器链中抛出的任何AccessDeniedException和AuthenticationException 。
    FilterSecurityInterceptor:负责权限校验的过滤器。

2、认证流程在这里插入图片描述
概念速查:
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);
    }
    // 注销登录,小坑:注意注销登录的接口不能直接是 /logout
    @Override
    public ResponseResult<String> logout() {
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        LoginUser principal = (LoginUser) authentication.getPrincipal();
        Integer userId = principal.getUser().getId();
        redisTemplate.delete(String.valueOf(userId));
        return new ResponseResult<>(200, "退出成功");
    }

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过滤器,配置在UsernamePasswordAuthenticationFilter过滤器前面
                .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,因为后面的过滤器需要对请求进行认证,可以判断 SecurityContextHolder 里的用户信息
        UsernamePasswordAuthenticationToken authenticationToken =
                new UsernamePasswordAuthenticationToken(loginUser,null,null);
        SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        //放行
        filterChain.doFilter(request, response);
    }
}

思考:

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

认证流程源码探查

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

1、在登录方法里点击校验方法 authenticat()。
在这里插入图片描述

2、跳转到 AuthenticationManager 接口,接下来进入实现该方法的类 ProviderManager。
在这里插入图片描述
在这里插入图片描述

3、在 ProviderManager 类的 authenticate() 方法中进入调用的方法 provider.authenticate() 中。
在这里插入图片描述

4、在 AuthenticationProvider 接口里找到实现 authenticate() 方法的类。
在这里插入图片描述
在这里插入图片描述

5、在 AbstractUserDetailsAuthenticationProvider 中进入方法 retrieveUser() 中。
在这里插入图片描述

6、进入继承抽象类 AbstractUserDetailsAuthenticationProvider 的 DaoAuthenticationProvider 类,在 retrieveUser() 方法中进入方法 loadUserByUsername() 中。
在这里插入图片描述
在这里插入图片描述

7、进入到了熟悉的接口 UserDetailsService 中,然后找实现该接口的方法。
在这里插入图片描述
在这里插入图片描述

8、最终形成一个闭环。
在这里插入图片描述

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

在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

sky丶Mamba

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值