如何使用Spring Security(Spring Security进阶版)

前言

本文讲解一下Spring Security如何使用,阅读本文之前最好先阅读一下我的另一篇文章,讲的是 Spring Security基础的源码https://blog.csdn.net/a771664696/article/details/141790526,基于上一篇源码的理解再来看如何正确的使用Spring Security,先附上代码的gitee地址,https://gitee.com/henryht/spring-security-demo.git

目的

首先,明确一下本文讲解的目的是搭建一个简易的Spring Security框架,为业务服务提供权限认证。先看一个非常简单的图,

  1. 前端先通过用户名密码调用后端的登录接口,登录成功后,后端返回一串token给到前端

  2. 当前端发起业务请求时再Header中携带这个token,后端会对这个业务请求进行拦截,并验证token的合法性

实际项目的接口如下几个图所示:

  1. 调用/auth/login接口获取token

  1. 拿到token之后,通过header中携带token完成认证,访问测试接口

回顾

在开始讲解之前,先来回顾一下上一篇文章的内容。还是这个经典的图,本文我们将对下面的几个成员按照真实的业务需求进行重写

登录源码分析

自定义过滤器CustomLoginUsernamePasswordFilter

按照上文的流程图,首先我们需要重写的是过滤器UsernamePasswordAuthenticationFilter,这个过滤器之前也是介绍了,主要负责的拦截登录的请求,然后将请求处理后交由后续的角色进行登录的流程操作

/**
 * 用户登录过滤器
 */
public class CustomLoginUsernamePasswordFilter extends UsernamePasswordAuthenticationFilter {

    private static final AntPathRequestMatcher DEFAULT_ANT_PATH_REQUEST_MATCHER =
            new AntPathRequestMatcher(SecurityConstants.LOGIN_URL, "POST");

    public CustomLoginUsernamePasswordFilter(CustomProvider customProvider) {
        this.setRequiresAuthenticationRequestMatcher(DEFAULT_ANT_PATH_REQUEST_MATCHER);
        this.setAuthenticationManager(new ProviderManager(customProvider));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
            throws AuthenticationException {
        if (!request.getMethod().equals("POST")) {
            throw new AuthenticationServiceException("custom Authentication method not supported: " + request.getMethod());
        }
        String username = obtainUsername(request);
        username = (username != null) ? username : "";
        username = username.trim();
        String password = obtainPassword(request);
        password = (password != null) ? password : "";
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(username, password);
        // Allow subclasses to set the "details" property
        setDetails(request, authRequest);
        return this.getAuthenticationManager().authenticate(authRequest);
    }

    /**
     * 重写授权成功方法,返回token
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                            FilterChain chain,
                                            Authentication authResult) throws IOException {
        UserDetails userDetails = (UserDetails) authResult.getPrincipal();
        //存入redis,返回生产的uuid token
        String token = UUID.randomUUID().toString().replaceAll("-", "");

        // 返回token
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write(token);
    }

    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException failed) throws IOException {
        SecurityContextHolder.clearContext();
        // 返回token
        response.setContentType("application/json;charset=UTF-8");
        response.getWriter().write("登录失败");
    }

}

这里我们自定义了一个CustomLoginUsernamePasswordFilter过滤器,继承了UsernamePasswordAuthenticationFilter类,然后重写了一些方法,首先是attemptAuthentication方法,这里其实并没有做太多改造。

但为什么这里也重写了这个方法呢?首先看看这个方法是在干嘛,这个方法首先从HttpServletRequest中拿到username和password,封装成一个UsernamePasswordAuthenticationToken对象,然后交由权限认证管理器处理。平时我们在和前端约定传值的时候不一定是按照这个方式来传的,比如有的可能就不使用form表单提交的形式,所以这里我们使用的时候可以按照自己的方式来传值

这里我们还重写了2个方法,一个是successfulAuthentication,这个方法之前讲过,在父类抽象类中它的作用是登录成功做一个重定向到指定的url页面,但是这里我们登录实际的需求是返回一串token给前端,这里正常来说需要存入redis中用于后续登录合法性校验,本文的demo就不做实现。

另一个方式是unsuccessfulAuthentication,这里也是同样的道理,它默认的实现是登录失败跳转登录失败页面,但因为我们是前后端分离,这里就是直接返回和前端约定的登录失败的code和提示即可。

权限认证过滤器ProviderManager

本文并没有对ProviderManager进行重写,因为这个权限认证过滤器的核心逻辑是去筛选一个符合条件的Provider进行处理

自定义权限认证处理器CustomProvider

这里自定义了CustomProvider用来替代DaoAuthenticationProvider来完成我们自己的逻辑。在这个权限认证处理器,核心方法是authenticate

  1. 这里我们可以进行一些自定义的日志打印

  2. 从自定义的customUserDetailService(这个也需要我们自定义)中获取UserDetails用于验证用户合法性,用户名密码等

  3. 通过自定的PasswordEncoder对密码进行加解密的验证,这里我使用的是md5的形式,大家也可以根据自己的需求对面进行不同形式的加解密验证,如AES等。

  4. 密码校验通过封装一个完整的UsernamePasswordAuthenticationToken返回。

@Component
public class CustomProvider implements AuthenticationProvider {
    Logger logger = LoggerFactory.getLogger(CustomProvider.class);
    private final PasswordEncoder passwordEncoder;
    private final CustomUserDetailService customUserDetailService;
    public CustomProvider(PasswordEncoder passwordEncoder,
                          CustomUserDetailService customUserDetailService) {
        this.passwordEncoder = passwordEncoder;
        this.customUserDetailService = customUserDetailService;
    }

    @Override
    public Authentication authenticate(Authentication authentication) throws AuthenticationException {
        logger.info("come into CustomProvider authentication {} ", authentication);
        //匹配账号密码
        String username = authentication.getName();
        String password = (String) authentication.getCredentials();
        UserDetails userDetails = customUserDetailService.loadUserByUsername(username);
        if (userDetails == null) {
            throw new BadCredentialsException("user does not exist");
        }
        if (!passwordEncoder.matches(password, userDetails.getPassword())) {
            throw new BadCredentialsException("wrong password");
        }
        // 返回一个认证对象
        return new UsernamePasswordAuthenticationToken(userDetails, passwordEncoder.encode(password), userDetails.getAuthorities());
    }

    @Override
    public boolean supports(Class<?> authentication) {
        return UsernamePasswordAuthenticationToken.class.isAssignableFrom(authentication);
    }
}

用户处理器CustomUserDetailService

我们知道Spring Security默认使用的是,InMemoryUserDetailsManager,但是我们生产环境不可能只用内存的形式来处理用户信息,所以我要通过创建一个新的CustomUserDetailService来替代它。

这个类其实大家应该会比较眼熟了,因为网上很多的文章都会去自己实现这个UserDetailsService这个接口。

看了本文之后相信大家也不仅能够知道实现它能干嘛,而且也能够了解为什么要实现它,以及它的整个调用链路。

该接口就只有一个方法loadUserByUsername,那么来看看这个方法内到底需要做什么

  1. 通过方法传入的username(账号)去数据中查询用户信息,这里我屏蔽了数据库的查询细节,因为是demo所以这里直接代码写死

  2. authorities为权限相关,本文暂不做深入讲解

  3. 查询完成后封装一个自定义的CustomUser用户对象返回,这里对象返回给上一层的CustomProvider,就可以为它提供密码校验的能力了

@Component
public class CustomUserDetailService implements UserDetailsService {

    Logger logger = LoggerFactory.getLogger(CustomUserDetailService.class);

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        logger.info("come into CustomProvider authentication {} ", username);
        // query from db
        String userName = "henry";
        List<GrantedAuthority> authorities = new ArrayList<>();
        SimpleGrantedAuthority simpleGrantedAuthority = new SimpleGrantedAuthority("ROLE_ADMIN");
        authorities.add(simpleGrantedAuthority);
        return new CustomUser(userName, SecureUtil.md5("123456"), authorities);
    }
}

对于CustomUser这里也是需要去实现它的UserDetails接口,这里面也是Spring Security抽象的一些行为

比如getPassword获取用户密码, getUsername查询用户账号,isAccountNonExpired账号是否过期等,总之按照它的规范来就行

 
public class CustomUser implements UserDetails {

    private String username;

    private String password;

    List<GrantedAuthority> authorities;

    public CustomUser(String username, String password, List<GrantedAuthority> authorities) {
        this.username = username;
        this.password = password;
        this.authorities = authorities;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return this.authorities;
    }

    @Override
    public String getPassword() {
        return this.password;
    }

    @Override
    public String getUsername() {
        return this.username;
    }

    @Override
    public boolean isAccountNonExpired() {
        return false;
    }

    @Override
    public boolean isAccountNonLocked() {
        return false;
    }

    @Override
    public boolean isCredentialsNonExpired() {
        return false;
    }

    @Override
    public boolean isEnabled() {
        return false;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public void setPassword(String password) {
        this.password = password;
    }

    public void setAuthorities(List<GrantedAuthority> authorities) {
        this.authorities = authorities;
    }
}

到这里登录流程就已经完结。

上面我们重写了很多Spring Security的类或者叫做组件吧,还需要把他们注入生效,这里需要一个配置类去创建它们。

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {

    @Resource
    private CustomProvider customProvider;

    @Bean
    public PasswordEncoder passwordEncoder() {
        //自定义密码加解密
        return new CustomPasswordEncoder();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        //关闭csrf
        http.csrf().disable()
                .anonymous().disable()
                // 禁用session
                .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                //添加provider
                .authenticationProvider(customProvider)
                .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
                    authorizationManagerRequestMatcherRegistry.antMatchers(SecurityConstants.LOGIN_URL).permitAll();
                    authorizationManagerRequestMatcherRegistry.antMatchers(SecurityConstants.LOGOUT_URL).permitAll();
                    authorizationManagerRequestMatcherRegistry.anyRequest().authenticated();
                    //可配置其他的静态访问资源
                })
                //添加登录拦截器
                .addFilterAt(new CustomLoginUsernamePasswordFilter(customProvider), UsernamePasswordAuthenticationFilter.class)
                //在CustomLoginUsernamePasswordFilter之前去执行
                .addFilterBefore(new CustomRequestUsernamePasswordFilter(), CustomLoginUsernamePasswordFilter.class)
                //退出登录
                .logout().logoutUrl(SecurityConstants.LOGOUT_URL).addLogoutHandler(new CustomLogoutHandler());
    }

}

鉴权源码分析

如上文所说,登录成功之后,我们会返回一个uuid用作token返回给前端。那么前端只需要把这个token按照一定的规则传给后端就可以进行账号登录的合法性验证了。

一般来说都是通过在Header中传入一个参数Authorization,然后以Bearer开头,空格加token进行传递。

所以这里我们需要个过滤器来拦截进入后端的每一个请求,从而达到校验用户登录的目的,那我们来看看这个拦截器做了什么

  1. 判断是否登录接口,如果是则默认放行,登录接口不用做校验

  2. 从header中解析出token

  3. 根据token去redis中查找是否存在,并取出redis中存储的用户信息,校验合法性

  4. 将从redis中取出的用户信息封装成UsernamePasswordAuthenticationToken放入SecurityContextHolder中。

public class CustomRequestUsernamePasswordFilter extends OncePerRequestFilter {

    Logger logger = LoggerFactory.getLogger(CustomRequestUsernamePasswordFilter.class);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        if (request.getRequestURI().contains(SecurityConstants.LOGIN_URL)) {
            //白名单放行,如果是/auth/login则证明是登录的接口,不需要校验token
            filterChain.doFilter(request, response);
            return;
        }
        String tokenHeader = request.getHeader("Authorization");
        if (!StringUtils.hasText(tokenHeader)) {
            // 返回token
            //正常这里要返回封装的result类
            response.setStatus(HttpStatus.UNAUTHORIZED.value());
            response.setContentType("application/json;charset=UTF-8");
            response.getWriter().write("认证失败");
            return;
        }
        String token = tokenHeader.replace("Bearer ", "");
        logger.info("token is {}", token);
        // 校验token合法性
        //... 省略逻辑 这里从redis中拿出
        // token续期
        //... 省略逻辑 这里每次访问都将redis中的token续期
        // 将redis中存放的用户信息,放入SecurityContextHolder中
        UserDetails userDetails = new CustomUser("henry", "", null);
        // 放入SecurityContextHolder中,这一步很重要,否则spring security无法认证成功,会被AuthorizationFilter拦截器拦住
        SecurityContextHolder.getContext()
                .setAuthentication(new UsernamePasswordAuthenticationToken(userDetails, null, null));
        filterChain.doFilter(request, response);
    }
}

总结

本文其实也只是一个很简单的demo,但是基本的Spring Security的使用思路基本也都涵盖了。这里也留一个问题,在微服务中,又要怎么来处理呢?留到下一篇文章再讲解

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值