登出功能

232 阅读5分钟

内置方案

spring security 为我们提供了一套登出功能的解决方案,若依正是使用了这套方案来处理登出功能。 打开spring security配置文件

package com.ruoyi.framework.config;
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig extends WebSecurityConfigurerAdapter
{
    /**
     * 自定义用户认证逻辑
     */
    @Autowired
    private UserDetailsService userDetailsService;
    
    /**
     * 认证失败处理类
     */
    @Autowired
    private AuthenticationEntryPointImpl unauthorizedHandler;

    /**
     * 退出处理类
     */
    @Autowired
    private LogoutSuccessHandlerImpl logoutSuccessHandler;

    /**
     * token认证过滤器
     */
    @Autowired
    private JwtAuthenticationTokenFilter authenticationTokenFilter;
    
    /**
     * 跨域过滤器
     */
    @Autowired
    private CorsFilter corsFilter;

    /**
     * 允许匿名访问的地址
     */
    @Autowired
    private PermitAllUrlProperties permitAllUrl;

    /**
     * 解决 无法直接注入 AuthenticationManager
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    public AuthenticationManager authenticationManagerBean() throws Exception
    {
        return super.authenticationManagerBean();
    }

    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
    @Override
    protected void configure(HttpSecurity httpSecurity) throws Exception
    {
        // 注解标记允许匿名访问的url
        ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry registry = httpSecurity.authorizeRequests();
        permitAllUrl.getUrls().forEach(url -> registry.antMatchers(url).permitAll());

        httpSecurity
                // CSRF禁用,因为不使用session
                .csrf().disable()
                // 禁用HTTP响应标头
                .headers().cacheControl().disable().and()
                // 认证失败处理类
                .exceptionHandling().authenticationEntryPoint(unauthorizedHandler).and()
                // 基于token,所以不需要session
                .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
                // 过滤请求
                .authorizeRequests()
                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                .antMatchers("/login", "/register", "/captchaImage").permitAll()
                // 静态资源,可匿名访问
                .antMatchers(HttpMethod.GET, "/", "/*.html", "/**/*.html", "/**/*.css", "/**/*.js", "/profile/**").permitAll()
                .antMatchers("/swagger-ui.html", "/swagger-resources/**", "/webjars/**", "/*/api-docs", "/druid/**").permitAll()
                // 除上面外的所有请求全部需要鉴权认证
                .anyRequest().authenticated()
                .and()
                .headers().frameOptions().disable();
        // 添加Logout filter
        httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);
        // 添加JWT filter
        httpSecurity.addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class);
        // 添加CORS filter
        httpSecurity.addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class);
        httpSecurity.addFilterBefore(corsFilter, LogoutFilter.class);
    }

    /**
     * 强散列哈希加密实现
     */
    @Bean
    public BCryptPasswordEncoder bCryptPasswordEncoder()
    {
        return new BCryptPasswordEncoder();
    }

    /**
     * 身份认证接口
     */
    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception
    {
        auth.userDetailsService(userDetailsService).passwordEncoder(bCryptPasswordEncoder());
    }
}

可以看到配置类里面写了很多自定义处理方案,如认证失败处理类和运行匿名访问的url,我们这里只关注登出功能。

// 添加Logout filter
httpSecurity.logout().logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler);

这个功能的实现本质上拦截器,也就是说spring security内置了一个拦截器去实现登出功能,当然我们也可以不使用该拦截器,去自定义实现登出功能。

logoutSuccessHandler(LogoutSuccessHandler logoutSuccessHandler)方法接收一个LogoutSuccessHandler接口实现类的实例,此接口也是spring security内置接口,需要我们自己去实现。

public interface LogoutSuccessHandler {
    void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException;
}

重写方法onLogoutSuccess里面写一些登出逻辑,比如删除redis存储用户信息,重定向等

业务流程

知道了spring security的内置处理方法再梳理一下流程。

1 前端向"/logout"发起请求,请求头需要带上token。

2 被拦截器截获去执行LogoutSuccessHandler实现类的重写方法

3 返回相应的信息(若依登出功能有一个bug,很小,虽然无伤大雅,下面细说)

4 前端收到成功信息去将本地的角色集和权限集置为空,再将存储在本地的token删除。

5 重定向到登入页面

具体实现

前端

async logout() {
  this.$confirm('确定注销并退出系统吗?', '提示', {
    confirmButtonText: '确定',
    cancelButtonText: '取消',
    type: 'warning'
  }).then(() => {
    this.$store.dispatch('LogOut').then(() => {
      location.href = '/index';
    })
  }).catch(() => {});
}

前端是使用了promise 来处理该请求,在点击弹出框的"确定"按钮后会对后端发起请求,如果成功了会重定向到/index,失败则不进行跳转。

后端

public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException
{

    LoginUser loginUser = tokenService.getLoginUser(request);
    if (StringUtils.isNotNull(loginUser))
    {
        String userName = loginUser.getUsername();
        // 删除用户缓存记录
        tokenService.delLoginUser(loginUser.getToken());
        // 记录用户退出日志
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
    }
    ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));
}

我们从域对象内获取loginuser对象后进行非空判断,判断通过后去删除redis相应的信息,然后异步的进行日志记录,最后返回成功信息。

退出功能一点小问题

如果我们在前端请求不携带token,后端就无法从域对象内获取用户信息,根据逻辑我们还是会向前端返回成功的信息。

image.png

export function logout() {
  return request({
    url: '/logout',
    method: 'post'
  })
}

前端登入使用post方法,而我们使用get方法也是可以成功。

也就是说只要我们向这个url发起请求就一定可以返回到成功信息。

改进代码

1 前端向"/logout"发起请求,请求头需要带上token。

2 被拦截器截获去执行LogoutSuccessHandler实现类的重写方法

3 对loginuser进行非空判断,为空则转发到自定义的异常控制器之中(在过滤器上的异常无法被全局异常处理器处理,需要转发到控制器上在抛出异常),抛出异常由自定义全局异常处理器处理

4 非空则返回成功信息

5 前端收到成功信息去将本地的角色集和权限集置为空,再将存储在本地的token删除。

6 重定向到登入页面

public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
        throws IOException, ServletException
{

    LoginUser loginUser = tokenService.getLoginUser(request);
    if (StringUtils.isNotNull(loginUser))
    {
        String userName = loginUser.getUsername();
        // 删除用户缓存记录
        tokenService.delLoginUser(loginUser.getToken());
        // 记录用户退出日志
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(userName, Constants.LOGOUT, "退出成功"));
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success("退出成功")));

    }
    request.getRequestDispatcher("/filter/logout").forward(request,response);

}
@RestController
@RequestMapping("/filter")
public class FilterErrorController {
    @PostMapping("/logout")
    public AjaxResult LogoutError(){
        throw new LogoutException("rouyi-framwork","用户未登入");
    }
}
@ExceptionHandler(LogoutException.class)
public AjaxResult handleLogoutException(LogoutException e){
    return AjaxResult.error(e.getDefaultMessage());
}

image.png