Back
Featured image of post Spring Security + JWT前后端分离认证授权

Spring Security + JWT前后端分离认证授权

一、Spring Security简介

前段时间一直在忙项目,没有时间写博客,最近抽出时间学习了一下Spring Security分享给大家。Spring Security是一款强大的安全框架,相信大家都知道项目中使用安全框架的主要作用就是认证授权

  • 认证:指确认当前访问用户在本系统的身份。
  • 授权:针对于用户的身份所办法的权限信息。

二、实战分析

1.思路分析

首先我们想要实现前后端分离的认证授权就不能像传统的web服务一样使用session,因此我们采用JWT这种无状态机制来生成token。通过token来进行认证授权,具体的流程如下:

  • 客户端通过调用服务端的登录接口,输入用户名、密码。登录成功后响应两个我们所需要的token,这两个token分别如下:

    • accessToken:客户端携带这个token访问服务端的资源。
    • refreshToken:刷新令牌,一旦accessToken过期了,客户端需要使用refreshToken重新获取一个accessToken。因此refreshToken的过期时间一般大于accessToken。
  • 客户请求服务带着accessToken来访问服务端的资源,服务端对accessToken进行校验:

    • 是否过期,如果过期则需带着refreshToken调用刷新令牌接口重新获取一个新的accessToken
    • 识别当前accessToken所对应的授权信息,并根据访问目标需要的权限和当前授权用户的权限做权限校验。

2.SpringSecurity流程分析

SpringSecurity实际上就是根据配置通过一整套过滤器链来对我们整体的流程做处理。所以了解SpringSecurity默认的处理流程能够让我们对认证授权有着更深入的理解。

这里列举出SpringSecurity的三个核心过滤器:

  • UsernamePasswordAuthenticationFilter:负责处理我们在登陆页面填写了用户名密码后的登陆请求,这个过滤器主要是整个授权功能的门户过滤器。
  • ExceptionTranslationFilter:处理过滤器链中抛出的任何AuthenticationException和AccessDeniedException,AuthenticationException是授权类型的异常、AccessDeniedException是认证类型的异常。
  • FilterSecurityInterceptor:负责权限校验的过滤器 。

认证授权

  • Authentication接口: 它的实现类,表示当前访问系统的用户,封装了用户相关信息。

  • AuthenticationManager接口:定义了认证Authentication的方法 。

  • UserDetailsService接口:加载用户特定数据的核心接口。里面定义了一个根据用户名查询用户信息的方法。

  • UserDetails接口:提供核心用户信息。通过UserDetailsService根据用户名获取处理的用户信息要封装成UserDetails对象返回。然后将这些信息封装到Authentication对象中。

鉴权

  • FilterSecurityInterceptor:负责权限校验的过滤器。
  • DefaultFilterInvocationSecurityMetadataSource:从配置中加载某次请求的权限配置信息。
  • AffirmativeBased:默认的决策器。

3.实战案例

通过对SpringSecurity的流程分析,实现我们需要的功能需要如下步骤:

  • 1.替换UsernamePasswordAuthenticationFilter来自定义认证拦截路径以及认证成功后或失败后的业务处理
  • 2.实现UserDetailsService,从自定义的数据源中加载用户以及用户对应的权限信息
  • 3.写一个过滤器过滤请求携带的token获取用户权限信息,存储在线程容器中
  • 5.对AccessDeniedException和AccessDeniedException进行异常处理,实现自定义的认证失败和鉴权失败逻辑
  • 6.重写权限配置加载器和决策器实现动态加载配置进行鉴权

JwtAuthenticationLoginFilter替换UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter实现AbstractAuthenticationProcessingFilter 因此我们只要重新实现这个过滤器即可自定义我们的过滤器。

/**
 * jwt身份过滤器,参照UsernamePasswordAuthenticationFilter
 * 可以实现我们自己的拦截逻辑,例如改变登录地址,校验验证码等
 *
 * @author zp
 */
public class JwtAuthenticationLoginFilter extends AbstractAuthenticationProcessingFilter {

    /**
     * 构造方法表示要匹配的拦截路径,这里我们对登录拦截
     *
     */
    public JwtAuthenticationLoginFilter() {
        super(new AntPathRequestMatcher("/login", "POST"));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException, IOException, ServletException {
        //获取表单提交数据
        String username = request.getParameter("username");
        String password = request.getParameter("password");
        //封装到token中提交
        UsernamePasswordAuthenticationToken authRequest = new UsernamePasswordAuthenticationToken(
                username,password);
        //这里调用
        return getAuthenticationManager().authenticate(authRequest);
    }
}

接下来我们要做此次登录失败和登录成功的逻辑处理:

  • 登录失败:判断登录失败原因,响应失败信息
  • 登录成功:颁发accessToken和refreshToken,响应成功信息

我们知道某次登录就是一个认证的流程而作为整个认证门户的AbstractAuthenticationProcessingFilter 这个抽象类给我们提供了方案,认证成功会调用successfulAuthentication()方法进而调用AuthenticationSuccessHandler这个接口的onAuthenticationSuccess()方法,认证失败同理。因此我们实现这个两个接口即可处理我们登录成功或登录失败的逻辑。

  • 登录成功处理器
/**
 * 登录认证成功处理器
 * AbstractAuthenticationProcessingFilter调用successfulAuthentication方法
 *
 * @author zp
 */
@Component
@RequiredArgsConstructor
public class LoginAuthenticationSuccessHandler  implements AuthenticationSuccessHandler {

    private  final JwtUtils jwtTokenUtil;


    /**
     * 身份验证成功,我们通常在这里下发令牌
     *
     */
    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        UserDetails userDetails = (UserDetails) authentication.getPrincipal();
        SecurityContextHolder.getContext().setAuthentication(authentication);
        //todo 业务逻辑 例如登录成功后,记录用户已经登录
        //生成令牌
        String accessToken = jwtTokenUtil.createToken(userDetails.getUsername());
        //生成刷新令牌
        String refreshToken = jwtTokenUtil.refreshToken(accessToken);
        renderToken(response, LoginToken.builder().accessToken(accessToken).refreshToken(refreshToken).build());
    }
    /**
     * 渲染返回 token 数据,因为前端页面接收的都是Result对象,故使用application/json返回
     */
    public void renderToken(HttpServletResponse response, LoginToken token) throws IOException {
        ResponseUtils.result(response,new BaseResp("200","登录成功!",token));
    }
}
  • 登录失败处理器
/**
 * 登录失败的处理器
 * AbstractAuthenticationProcessingFilter调用unsuccessfulAuthentication方法
 *
 * @author zp
 */
@Component
public class LoginAuthenticationFailureHandler implements AuthenticationFailureHandler {

    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        if (exception instanceof BadCredentialsException){
            ResponseUtils.result(response,new BaseResp<>("501","用户名密码不正确"));
        }
        ResponseUtils.result(response,new BaseResp("502","登录失败"));
    }
}

LoginServiceImpl实现UserDetailsService

/**
 * 登录服务impl,实现UserDetailsService
 * security的整个认证流程会通过UserDetailsService的实现类来获取用户,这里我们实现它
 *
 * @author zp
 */

@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService, UserDetailsService {
    @Autowired
    private  PasswordEncoder passwordEncoder;

    private final UserRepository userRepository;
    private final UserRoleRepository userRoleRepository;
    private final RoleAuthRepository roleAuthRepository;
    private final RoleRepository roleRepository;
    private final AuthRepository authRepository;


    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        //获取用户信息
        User user = userRepository.findByUsernameAndStatus(username,CONSTANT_ONE);
        SecurityUser securityUser=null;
        if (Objects.nonNull(user)){
            securityUser = new SecurityUser();
            securityUser.setUsername(username);
            securityUser.setPassword(user.getPassword());
            //查询该用户的角色
            List<UserRole> userRoles = userRoleRepository.findByUserIdAndStatus(user.getUserId(),CONSTANT_ONE);
            //获取权限集合
            Collection<? extends GrantedAuthority> authorities = merge(userRoles);
            securityUser.setAuthorities(authorities);
        }
        //从数据库中查询
        //用户不存在直接抛出UsernameNotFoundException,security会捕获抛出BadCredentialsException
        if (Objects.isNull(securityUser))
            throw new UsernameNotFoundException("用户不存在!");
        return securityUser;
    }

    /**
     * 查询角色
     */
    private List<String> listRoles(List<UserRole> userRoles){
        List<String> list=new ArrayList<>();
        if (!CollectionUtils.isEmpty(userRoles)){
            userRoles.forEach(param-> list.add(roleRepository
                    .selectById(param.getRoleId()).getName())
            );
        }
        return list;
    }

    /**
     * 查询权限
     */
    private List<String> listAuths(List<UserRole> userRoles){
        List<String> list=new ArrayList<>();
        if (!CollectionUtils.isEmpty(userRoles)){
            userRoles.forEach(param->{
                List<RoleAuth> roleAuths = roleAuthRepository.findByRoleIdAndStatus(param.getRoleId(), CONSTANT_ONE);
                //查询权限
                if (!CollectionUtils.isEmpty(roleAuths)){
                    roleAuths.forEach(o-> list.add(authRepository
                            .selectById(o.getAuthId())
                            .getName()));
                }
            });
        }
        return list;
    }
    private Collection<? extends GrantedAuthority> merge(List<UserRole> userRoles){
        List<String> roles = listRoles(userRoles);
        List<String> auths = listAuths(userRoles);
        String[] a={};
        roles.addAll(auths);
        return AuthorityUtils.createAuthorityList(roles.toArray(a));
    }
}

配置jwtFilter

前面写好了整个认证过程的流程因为我们替换了UsernamePasswordAuthenticationFilter此处要进行对JwtAuthenticationLoginFilter进行配置。

/**
 * Security配置类,用于将jwtFilter替换UsernamePasswordAuthenticationFilter,
 * 并将jwtFilter放在UsernamePasswordAuthenticationFilter过滤器前执行
 *
 * @author zp
 */
@Configuration
@RequiredArgsConstructor
public class JwtAuthenticationSecurityConfig extends SecurityConfigurerAdapter<DefaultSecurityFilterChain, HttpSecurity> {
    /**
     * userDetailService
     */
    @Qualifier("loginServiceImpl")
    @Autowired
    private UserDetailsService userDetailsService;

    /**
     * 登录成功处理器
     */
    private final LoginAuthenticationSuccessHandler loginAuthenticationSuccessHandler;

    /**
     * 登录失败处理器
     */
    private final LoginAuthenticationFailureHandler loginAuthenticationFailureHandler;
    /**
     * 加密
     */
    @Autowired
    private  PasswordEncoder passwordEncoder;

    @Override
    public void configure(HttpSecurity http) {
        JwtAuthenticationLoginFilter filter = new JwtAuthenticationLoginFilter();
        filter.setAuthenticationManager(http.getSharedObject(AuthenticationManager.class));
        //认证成功处理器
        filter.setAuthenticationSuccessHandler(loginAuthenticationSuccessHandler);
        //认证失败处理器
        filter.setAuthenticationFailureHandler(loginAuthenticationFailureHandler);
        //直接使用DaoAuthenticationProvider
        DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
        //设置userDetailService
        provider.setUserDetailsService(userDetailsService);
        //设置加密算法
        provider.setPasswordEncoder(passwordEncoder);
        http.authenticationProvider(provider);
        //将这个过滤器添加到UsernamePasswordAuthenticationFilter之前执行
        http.addFilterBefore(filter, UsernamePasswordAuthenticationFilter.class);
    }

}

编写TokenAuthenticationFilter过滤器

编写TokenAuthenticationFilter用做认证完成后对下次请求携带的token进行处理

/**
 * 校验token的过滤器,直接获取header中的token进行校验,
 */
public class TokenAuthenticationFilter extends OncePerRequestFilter {
    /**
     * JWT的工具类
     */
    @Autowired
    private JwtUtils jwtUtils;

    /**
     * UserDetailsService的实现类,从数据库中加载用户详细信息
     */
    @Qualifier("loginServiceImpl")
    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException {
        String token = request.getHeader("token");
        /**
         * token存在则校验token
         * 1. token是否存在
         * 2. token存在:
         *  2.1 校验token中的用户名是否失效
         */
        if (StringUtils.hasText(token)){
            String username = jwtUtils.getUsernameFromToken(token);
            //SecurityContextHolder.getContext().getAuthentication()==null 未认证则为true
            if (StringUtils.hasText(username) && SecurityContextHolder.getContext().getAuthentication()==null){
                UserDetails userDetails = userDetailsService.loadUserByUsername(username);
                //如果token有效
                if (jwtUtils.validateToken(token,userDetails)){
                    // 将用户信息存入 authentication,方便后续校验
                    UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null,
                            userDetails.getAuthorities());
                    authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
                    // 将 authentication 存入 ThreadLocal,方便后续获取用户信息
                    SecurityContextHolder.getContext().setAuthentication(authentication);
                }
            }
        }
        //继续执行下一个过滤器
        chain.doFilter(request,response);
    }
}

处理AuthenticationException和AccessDeniedException

AuthenticationException会通过AuthenticationEntryPoint接口的commence方法进行处理,AccessDeniedException会通过AccessDeniedHandler接口的handle方法进行处理因此我们重写这两个接口做处理即可。

/**
 * 认证失败通过AuthenticationFailureHandler进行过处理
 * 因此AuthenticationEntryPoint我们用作用户访问受保护的资源,但是用户没有通过认证则会进入这个处理器
 *
 * @author zp
 */
@Component
public class EntryPointUnauthorizedHandler implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        ResponseUtils.result(response,new BaseResp("401","未认证,请您先认证!"));
    }
}
/**
 *
 * AccessDeniedHandler主要为授权失败的处理类
 * 当认证后的用户访问受保护的资源时,权限不够,则会进入这个处理器
 */
@Component
public class RequestAccessDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        ResponseUtils.result(response,new BaseResp("403","您的权限不足!"));
    }
}

动态加载资源所需权限并重写权限决策器

FilterSecurityInterceptor是权限的过滤器,我们只需要实现FilterInvocationSecurityMetadataSource接口即可为权限过滤器填充所需要的权限配置信息,而重写AccessDecisionManager接口即可根据我们自定义的逻辑对权限进行决策。

/**
 *该类的主要功能就是通过当前的请求地址,获取该地址需要的用户角色。
 */
@Component
@Slf4j
public class DynamicSecurityMetadataSource implements FilterInvocationSecurityMetadataSource {
    @Autowired
    private AuthorizationUtils authorizationUtils;
    @Override
    public Collection<ConfigAttribute> getAttributes(Object object) throws IllegalArgumentException {
        // 获取url
        FilterInvocation filterInvocation = (FilterInvocation) object;
        String requestUrl = filterInvocation.getRequestUrl();
        if (requestUrl.equals(Atom.HEALTH)){
            return null;
        }
        // 获取拥有url的角色集合(从数据库中加载)
        List<String> roles = authorizationUtils.listRoles(requestUrl);
        log.info("{} 对应的角色。{}",requestUrl,roles);
        //如果角色集合为空,则返回null
        if (CollectionUtils.isEmpty(roles)) {
            return null;
        }
        // 自定义角色信息 --> Security的权限格式
        String[] attributes = roles.toArray(new String[0]);
        return SecurityConfig.createList(attributes);
    }

    @Override
    public Collection<ConfigAttribute> getAllConfigAttributes() {
        return null;
    }

    @Override
    public boolean supports(Class<?> clazz) {
        return false;
    }
}
/**
 * 动态访问决策管理器,判断当前用户是否具备指定的角色。
 * 参考自AffirmativeBased默认使用这个决策器
 *
 * @author ZP
 */
@Component
@Slf4j
public class DynamicAccessDecisionManager implements AccessDecisionManager {
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes) throws AccessDeniedException, InsufficientAuthenticationException {
        // 获取用户拥有的权限信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        // 这里判断用户拥有的角色和该url需要的角色是否有匹配
        for (ConfigAttribute configAttribute : configAttributes) {
            String attribute = configAttribute.getAttribute();
            for (GrantedAuthority authority : authorities) {
                if (attribute.equals(authority.getAuthority())) {
                    log.info("匹配成功.");
                    return;
                }
            }
        }
        // 没有匹配就抛出异常
        throw new AccessDeniedException("权限不足,无法访问");
    }

    // 此 AccessDecisionManager 实现是否可以处理传递的 ConfigAttribute
    @Override
    public boolean supports(ConfigAttribute attribute) {
        return true;
    }

    // 此 AccessDecisionManager 实现是否能够提供该对象类型的访问控制决策。
    @Override
    public boolean supports(Class<?> clazz) {
        return true;
    }
}

最终整合整体配置

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true,jsr250Enabled = true,securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    @Lazy
    private  JwtAuthenticationSecurityConfig jwtAuthenticationSecurityConfig;
    @Autowired
    private EntryPointUnauthorizedHandler entryPointUnauthorizedHandler;
    @Autowired
    private  RequestAccessDeniedHandler requestAccessDeniedHandler;
    @Autowired
    private DynamicSecurityMetadataSource dynamicSecurityMetadataSource;
    @Autowired
    private DynamicAccessDecisionManager dynamicAccessDecisionManager;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.formLogin()
                //禁用表单登录,前后端分离用不上
                .disable()
                //应用登录过滤器的配置,配置分离
                .apply(jwtAuthenticationSecurityConfig)
                .and()
                // 设置URL的授权
                .authorizeRequests()
                //这个ObjectPostProcessor为了运行时动态让开发者、让spring security框架本身进行相关数据的扩展和填充。
                .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
                    @Override
                    public <T extends FilterSecurityInterceptor> T postProcess(T o) {
                        //SecurityMetadataSource的实现类
                        o.setSecurityMetadataSource(dynamicSecurityMetadataSource);
                        //决策器实现
                        o.setAccessDecisionManager(dynamicAccessDecisionManager);
                        return o;
                    }
                })
                // 这里需要将登录页面放行,permitAll()表示不再拦截,/login 登录的url,/refreshToken刷新token的url
                .antMatchers("/login", "/refreshToken")
                .permitAll()
                //hasRole()表示需要指定的角色才能访问资源
//                .antMatchers("/hello").hasRole("user")
                // anyRequest() 所有请求   authenticated() 必须被认证
                .anyRequest()
                .authenticated()

                //处理异常情况:认证失败和权限不足
                .and()
                .exceptionHandling()
                //认证未通过,不允许访问异常处理器
                .authenticationEntryPoint(entryPointUnauthorizedHandler)
                //认证通过,但是没权限处理器
                .accessDeniedHandler(requestAccessDeniedHandler)

                .and()
                //禁用session,JWT校验不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS)

                .and()
                //将TOKEN校验过滤器配置到过滤器链中,否则不生效,放到UsernamePasswordAuthenticationFilter之前
                .addFilterBefore(authenticationTokenFilterBean(), UsernamePasswordAuthenticationFilter.class)
                // 关闭csrf
                .csrf().disable();
    }
    /**
     * 自定义的token过滤器
     *
     */
    @Bean
    public TokenAuthenticationFilter authenticationTokenFilterBean() {
        return new TokenAuthenticationFilter();
    }
    /**
     * 加密
     *
     */
    @Bean
    public PasswordEncoder getPasswordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }
}

四、测试

  • 访问登录接口进行登录

  • 未携带token访问

  • 携带token访问未授权资源

五、示例源码及总结

上述案例的源代码都已放到我的github上了zpbaba/springSecurityOauth2Learn at develop (github.com)稍作修改即可适用于自己的系统,可以的话请帮我点个星,后续将为大家继续分享微服务统一认证的授权方案。