前后端分离项目整合spring security5

spring security的处理流程

spring security采用一系列链式操作来完成认证和鉴权任务。它的总体流程在江南一点雨的博客中有写,这里就不在罗列了。这里仅对我们需要自定义的部分进行提取。

Spring Security

登录处理

Spring Security 默认使用form表单进行登录,在基本的无配置情况下他会在使用自带的登录页面,并且在控制台打印出一串密码以进行登录验证。而这显然不符合实际的情况。所以,Spring Security提供基本的配置类来进行设置。只要在配置类中继承WebSecurityConfigurerAdapter就可以对Spring Security进行简单且高效的配置。

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {@Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().cors()
                .and()
                .formLogin()
                .loginPage("/login")
                .usernameParameter("loginName")
                .passwordParameter("passwd")
                .successForwardUrl("/index")
                .failureForwardUrl("/error");
    }
}

登录过程实现

只要使用.loginPage()就可以指定自己编写的登录界面了。但是需要注意的是默认情况下表单中的参数名必须得是usernamepassword。如果参数名不一致的话,spring security是无法进行匹配的。当然你也可以使用.usernameParameter.passwordParameter来指定前端传来的from表单内容。除此之外,我们还需要实现一个UserDetailService接口来从数据库或内存中获取正确的User信息用以判断登录成功与否。

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private UserService userService;

    @Autowired
    public void setUserService(UserService userService) {
        this.userService = userService;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        SysUser sysUser = userService.findByLoginName(username);

        if (sysUser == null) {
            throw new UsernameNotFoundException("用户名或密码不正确");
        }
        return new UserDetails("具体数据");
    }

}

该接口只有一个loadUserByUsername方法需要实现该方法需要返回一个UserDetail类用以存放真实的用户信息,这个UserDetail也是一个接口。
源码如下:

public interface UserDetails extends Serializable {

    Collection getAuthorities();

    String getPassword();

    String getUsername();

    boolean isAccountNonLocked();

    boolean isCredentialsNonExpired();

    boolean isEnabled();
}

spring security也提供实现类User,一般情况下拿来即用就行,也可以通过继承进行拓展。具体根据实际的业务逻辑来决定。最后在配置类中将写好的UserDetailService载入AuthenticationManager即可。这样就能将登录过程完全托管给spring security来处理了。

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        auth.userDetailsService(userDetailsService);
    }

登录成功和登录失败处理

但是很遗憾啊,公司里的项目是前后端分离式的,所以上面的配置并不能满足我们的需求。前后端分离项目的特点就是前后端之间只是用json数据进行传输,页面路由完全由前端控制。所以合理解决方案是,在登录成功后前端发送一个携带凭证的json数据,登录失败的话也返回对应的json数据。
根据上面的流程图可以看出,在登录验证之后,SpringSecurity 会根据验证结果,进入AuthenticationFailureHandlerAuthenticationSuccessHandler中,所以只要我们实现这两个接口,就可以对登录处理进行自定义了。
登录失败的话我们将失败原因封装成固定的json格式返回回去即可。

@Component
public class LoginFailureHandler implements AuthenticationFailureHandler {
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
        OutputStream out = response.getOutputStream();
        String result;
        ObjectMapper mapper = new ObjectMapper();

        if (exception instanceof BadCredentialsException) {
            result = mapper.writeValueAsString(BaseResponse.fail("用户名或者密码错误"));
        } else if (exception instanceof DisabledException) {
            result = mapper.writeValueAsString(BaseResponse.fail("该账户已禁用"));
        } else {
            result = mapper.writeValueAsString(BaseResponse.fail(exception.getMessage()));
        }


        out.write(result.getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }
}

登录成功的逻辑也差不多,生成一个特定的凭证返回给前端使用就好,这里我们采用jwt(json web token)作为凭证。

@Component
public class LoginSuccessHandler implements AuthenticationSuccessHandler {

    @Override
    public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
        response.setContentType("application/json; charset=UTF-8");
        ServletOutputStream out = response.getOutputStream();

        out.write(getResponse(authentication).getBytes(StandardCharsets.UTF_8));

        out.flush();
        out.close();
    }

    private String getResponse(Authentication authentication) throws JsonProcessingException {
//        获取spring security user对象
        User user = (User) authentication.getPrincipal();
//        用jwt工具类根据用户信息来生成token
        String token = JwtUtil.sign(user);
//        将spring security user转化成vo传回去
        LoginUserVo lv = new LoginVo(user,token);
        BaseResponse result = BaseResponse.success(lv);

        ObjectMapper mapper = new ObjectMapper();
        return mapper.writeValueAsString(result);
    }
}

最后别忘记将两个自定义的实现类编入spring security中去

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.csrf().disable().cors()
                .and()
//      登录验证逻辑
                .formLogin()
                .loginPage("/login")
                .loginProcessingUrl("/login")
                .usernameParameter("loginName")
                .passwordParameter("passwd")
                .successHandler(loginSuccessHandler)
                .failureHandler(loginFailureHandler);

登出处理

因为采用的jwt机制的无状态服务,而且jwt是无法手动注销的,所以其实登出操作只要在前端把token从缓存中删除就可以了。不过由于我们采用了无状态服务,所以还要配置将session给关闭

                http.sessionManagement()
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS);

虽然session和jwt相互并不冲突,但是为了效率考量还是关闭session比较合理,不然服务端会积累太多session id的。
当然如果是用了redis等缓存机制,还要在登出成功后在缓存中也将token进行同步删除。这一点可以通过实现LogoutSuccessHandler来实现,详见上述流程图。

认证验证

将整个登录登出模块配置完成之后,就需要解决如何验证已登录问题,而spring security自生自然使用session id 来进行验证的,但是之前我们已经将session给关闭了。所以就要用到我们自己派发的jwt了,只要一个请求的请求头中携带了我们的jwt,且jwt未过期,我们就认为他是已经登录。
而要实现这个需求,自然是又要自定义一个过滤器了。这次我们采用继承BasicauthenticationFilter 且重写doFilterInternal 的方式来实现。

public class JwtAuthenticationFilter extends BasicAuthenticationFilter {

    public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
        super(authenticationManager);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
        String token = request.getHeader("Authorization");

        if (JwtUtil.verify(token)) {

            UsernamePasswordAuthenticationToken authToken =
                    new UsernamePasswordAuthenticationToken("username", null,
                            "权限列表");

//            将token中的信息放到security context中用以后续验证
            SecurityContextHolder.getContext().setAuthentication(authToken);
        }
        chain.doFilter(request, response);
    }
}

这里的逻辑也相当简单,从前端的请求头中获取jwt判断是否有效,如果有效则在spring security context中设置相应的权限,否则就直接交给之后的过滤器来验证。唯一需要注意的就是这个UsernamePasswordAuthenticationToken这是spring security中authentication 的一个具体实现。可以从中通过getPrncipal来获取当前的用户信息,通过getCredentials来获取当前用户的密码,通过getDetails来获取请求的更多详细信息。然后因为我们是使用jwt作为凭证的,自然不可能在里面存放密码,所以就将密码设为null了。值得一提的是,最后一项权限列表是必填的不能为null。即使你的业务逻辑里没有权限认证,你也需要提供一个权限作为认证权限,不然即使已登录也是无法访问到任何controller的。
最后将这个过滤器在配置类中进行配置。

http.addFilter(getJwtAuthenticationFilter());

添加过滤器一共有四种方式addFilterAt,addFilterBefore,addFilterAfter,addFilter。基本上可以做到见名知义,而他生效原理么,就和spring security中对过滤的实现方式有关了。spring security会维护一个filter序列,并通过优先级来判断当前应该执行哪个过滤器,spring security对自己实现过滤器已经有了默认的优先级配置,所以前三个方法分别可以获取目标过滤器的优先级,优先级+1,优先级-1。如果两个过滤器的优先级相同的话会优先执行我们自定义的过滤器。详见原文总结的相当全面了。然后就是我们这里用到的addFilter了,使用这个方法的话spring security会去判断这个过滤器是否已经注册到filter列表中,如果没有就会报没有指定优先级错

public HttpSecurity addFilter(Filter filter) {
        Class filterClass = filter.getClass();
        if (!comparator.isRegistered(filterClass)) {
            throw new IllegalArgumentException(
                    "The Filter class "
                            + filterClass.getName()
                            + " does not have a registered order and cannot be added without a specified order. Consider using addFilterBefore or addFilterAfter instead.");
        }
        this.filters.add(filter);
        return this;
    }
    public boolean isRegistered(Class filter) {
        return getOrder(filter) != null;
    }

很显然我们自定义的过滤器没有被注册,但是为啥没有报错呢,那肯定能想到是因为继承了父类的优先级,而事实也是如此:

    private Integer getOrder(Class clazz) {
        while (clazz != null) {
            Integer result = filterToOrder.get(clazz.getName());
            if (result != null) {
                return result;
            }
            clazz = clazz.getSuperclass();
        }
        return null;
    }

认证和权限异常处理

在完成了登录验证之后,自然是要对未登录的请求进行拦截和向前端发送对应信息了。拦截这一部分spring security可以帮我们做,但是前后端分离状态的消息回送自然要我们来手动处理。参考上面的流程图可以得知,spring security的异常处理有两个入口,一是认证异常处理AuthenticationEntryPoint,一是权限不足异常处理AccessDeniedHandler。所以只要实现这个两个接口并在配置类中进行处理就行了。

@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        OutputStream out = response.getOutputStream();

        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(401);
        String result = mapper.writeValueAsString(BaseResponse.fail("用户未认证"));

        out.write(result.getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }
}
@Component
public class JwtDeniedHandler implements AccessDeniedHandler {
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException) throws IOException, ServletException {
        OutputStream out = response.getOutputStream();

        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(403);
        String result;
        if (accessDeniedException instanceof AuthorizationServiceException) {
            result = mapper.writeValueAsString(BaseResponse.fail("无访问权限"));
        } else {
            result = mapper.writeValueAsString(BaseResponse.fail(accessDeniedException.getMessage()));
        }

        out.write(result.getBytes(StandardCharsets.UTF_8));
        out.flush();
        out.close();
    }
}

关键词

springboot, spring security, 前后端分离, jwt, restful


纯小白,写的有点乱,如有问题还请指正orz,会不断完善内容的。
参考自江南一点雨大神的博客。原文指路

你可能感兴趣的:(前后端分离项目整合spring security5)