若依前后端分离版学习笔记(五)——Spring Boot简介与Spring Security

一 Spring Boot 简介

1、介绍

Spring Boot是一款开箱即用框架,提供各种默认配置来简化项目配置。让我们的Spring应用变的更轻量化、更快的入门。 在主程序执行main函数就可以运行。你也可以打包你的应用为jar并通过使用java -jar来运行你的Web应用。它遵循"约定优先于配置"的原则, 使用SpringBoot只需很少的配置,大部分的时候直接使用默认的配置即可。同时可以与Spring Cloud的微服务无缝结合。

Spring Boot2.x版本环境要求必须是jdk8或以上版本,服务器Tomcat8或以上版本

2、优点

  • 使编码变得简单: 推荐使用注解
  • 使配置变得简单: 自动配置、快速集成新技术能力 没有冗余代码生成和XML配置的要求
  • 使部署变得简单: 内嵌Tomcat、Jetty、Undertow等web容器,无需以war包形式部署
  • 使监控变得简单: 提供运行时的应用监控
  • 使集成变得简单: 对主流开发框架的无配置集成
  • 使开发变得简单: 极大地提高了开发快速构建项目、部署效率

二 Spring Security安全控制

1、介绍

Spring Security是一个能够为基于Spring的企业应用系统提供声明式的安全访问控制解决方案的安全框架

2、功能

Authentication 认证,就是用户登录
Authorization 授权,判断用户拥有什么权限,可以访问什么资源

安全防护,跨站脚本攻击,session攻击等

非常容易结合Spring进行使用

3、Spring Security与Shiro的区别

  • 相同点
    1.认证功能
    2.授权功能
    3.加密功能
    4.会话管理
    5.缓存支持
    6.rememberMe功能
    ...
  • 不同点
    优点:
    1.Spring Security基于Spring开发,项目如果使用Spring作为基础,配合Spring Security做权限更加方便。而Shiro需要和Spring进行整合开发
    2.Spring Security功能比Shiro更加丰富,例如安全防护方面
    3.Spring Security社区资源相对比Shiro更加丰富
    缺点:
    1.Shiro的配置和使用比较简单,Spring Security上手复杂些
    2.Shiro依赖性低,不需要依赖任何框架和容器,可以独立运行。Spring Security依赖Spring容器

上述所有内容来自ruoyi官方文档

4、Spring Security配置介绍

配置类为ruoyi-framework模块中com.ruoyi.framework.config下的SecurityConfig类

java 复制代码
package com.ruoyi.framework.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.logout.LogoutFilter;
import org.springframework.web.filter.CorsFilter;
import com.ruoyi.framework.config.properties.PermitAllUrlProperties;
import com.ruoyi.framework.security.filter.JwtAuthenticationTokenFilter;
import com.ruoyi.framework.security.handle.AuthenticationEntryPointImpl;
import com.ruoyi.framework.security.handle.LogoutSuccessHandlerImpl;

/**
 * spring security配置
 * 
 * @author ruoyi
 */
@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true)
@Configuration
public class SecurityConfig
{
    /**
     * 自定义用户认证逻辑
     */
    @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;

    /**
     * 身份验证实现
     */
    @Bean
    public AuthenticationManager authenticationManager()
    {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }

    /**
     * anyRequest          |   匹配所有请求路径
     * access              |   SpringEl表达式结果为true时可以访问
     * anonymous           |   匿名可以访问
     * denyAll             |   用户不能访问
     * fullyAuthenticated  |   用户完全认证可以访问(非remember-me下自动登录)
     * hasAnyAuthority     |   如果有参数,参数表示权限,则其中任何一个权限可以访问
     * hasAnyRole          |   如果有参数,参数表示角色,则其中任何一个角色可以访问
     * hasAuthority        |   如果有参数,参数表示权限,则其权限可以访问
     * hasIpAddress        |   如果有参数,参数表示IP地址,如果用户IP和参数匹配,则可以访问
     * hasRole             |   如果有参数,参数表示角色,则其角色可以访问
     * permitAll           |   用户可以任意访问
     * rememberMe          |   允许通过remember-me登录的用户访问
     * authenticated       |   用户登录后可访问
     */
    @Bean
    protected SecurityFilterChain filterChain(HttpSecurity httpSecurity) throws Exception
    {
        return httpSecurity
            // CSRF禁用,因为不使用session
            .csrf(csrf -> csrf.disable())
            // 禁用HTTP响应标头
            .headers((headersCustomizer) -> {
                headersCustomizer.cacheControl(cache -> cache.disable()).frameOptions(options -> options.sameOrigin());
            })
            // 认证失败处理类
            .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
            // 基于token,所以不需要session
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            // 注解标记允许匿名访问的url
            .authorizeHttpRequests((requests) -> {
                permitAllUrl.getUrls().forEach(url -> requests.antMatchers(url).permitAll());
                // 对于登录login 注册register 验证码captchaImage 允许匿名访问
                requests.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();
            })
            // 添加Logout filter
            .logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))
            // 添加JWT filter
            .addFilterBefore(authenticationTokenFilter, UsernamePasswordAuthenticationFilter.class)
            // 添加CORS filter
            .addFilterBefore(corsFilter, JwtAuthenticationTokenFilter.class)
            .addFilterBefore(corsFilter, LogoutFilter.class)
            .build();
    }

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

核心配置都在这里

开启安全注解@EnableMethodSecurity(prePostEnabled = true, securedEnabled = true),默认是禁用的。prePostEnabled方法级的控制访问权限

4.1 强散列哈希加密实现

方法bCryptPasswordEncoder()返回强散列哈希加密对象

4.2 身份认证实现

新建一个Spring Security内置的对象DaoAuthenticationProvider,set自定义的用户验证处理UserDetailsService以及密码加密方式

我们再来看一下用户验证处理UserDetailsService的实现

java 复制代码
package com.ruoyi.framework.web.service;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.enums.UserStatus;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysUserService;

/**
 * 用户验证处理
 *
 * @author ruoyi
 */
@Service
public class UserDetailsServiceImpl implements UserDetailsService
{
    private static final Logger log = LoggerFactory.getLogger(UserDetailsServiceImpl.class);

    @Autowired
    private ISysUserService userService;
    
    @Autowired
    private SysPasswordService passwordService;

    @Autowired
    private SysPermissionService permissionService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        // 从数据库用户表中查询用户信息
        SysUser user = userService.selectUserByUserName(username);
        /*
         * 根据查询结果抛出不同异常 
         */
        if (StringUtils.isNull(user))
        {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException(MessageUtils.message("user.not.exists"));
        }
        // 根据当前代码,不会有该情况,从userService.selectUserByUserName(username)查询里已经将删除状态的用户过滤掉了
        else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException(MessageUtils.message("user.password.delete"));
        }
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException(MessageUtils.message("user.blocked"));
        }
        
        // 如果用户存在,则判断用户密码是否正确
        passwordService.validate(user);

        // 密码正确返回新建LoginUser()
        return createLoginUser(user);
    }

    public UserDetails createLoginUser(SysUser user)
    {
        return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
    }
}

4.3 Spring Security相关配置

返回一个配置好的HttpSecurity,其中配置了哪些内容,代码上都有注释。我们来看一下自定义的认证失败处理类AuthenticationEntryPointImpl

java 复制代码
package com.ruoyi.framework.security.handle;

import java.io.IOException;
import java.io.Serializable;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.HttpStatus;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;

/**
 * 认证失败处理类 返回未授权
 * 
 * @author ruoyi
 */
@Component
public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint, Serializable
{
    private static final long serialVersionUID = -8970718410437077606L;

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e)
            throws IOException
    {
        // 返回错误消息和状态码
        int code = HttpStatus.UNAUTHORIZED;
        String msg = StringUtils.format("请求访问:{},认证失败,无法访问系统资源", request.getRequestURI());
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.error(code, msg)));
    }
}

自定义退出处理类LogoutSuccessHandlerImpl

java 复制代码
package com.ruoyi.framework.security.handle;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.web.service.TokenService;

/**
 * 自定义退出处理类 返回成功
 * 
 * @author ruoyi
 */
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
    @Autowired
    private TokenService tokenService;

    /**
     * 退出处理
     * 
     * @return
     */
    @Override
    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, MessageUtils.message("user.logout.success")));
        }
        // 返回退出登录成功信息
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success(MessageUtils.message("user.logout.success"))));
    }
}

JWT过滤,自定义的token认证过滤器 JwtAuthenticationTokenFilter

java 复制代码
package com.ruoyi.framework.security.filter;

import java.io.IOException;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.web.service.TokenService;

/**
 * token过滤器 验证token有效性
 * 
 * @author ruoyi
 */
@Component
public class JwtAuthenticationTokenFilter extends OncePerRequestFilter
{
    @Autowired
    private TokenService tokenService;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException
    {
        // 从请求中获取用户信息
        LoginUser loginUser = tokenService.getLoginUser(request);
        // 如果用户信息不为null且未进行认证(安全认证为null)
        if (StringUtils.isNotNull(loginUser) && StringUtils.isNull(SecurityUtils.getAuthentication()))
        {
            // 验证token是否有效并重置token有效时间
            tokenService.verifyToken(loginUser);
            // 进行安全认证并set到Spring Security上下文中
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(loginUser, null, loginUser.getAuthorities());
            authenticationToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
            SecurityContextHolder.getContext().setAuthentication(authenticationToken);
        }
        // 继续过滤器链
        chain.doFilter(request, response);
    }
}

关于CSRF、JWT以及CORS的一些概念

  1. 关于CSRF:
  • CSRF(跨站请求伪造)是一种攻击,攻击者诱导用户在当前已登录的Web应用上执行非本意的操作。这种攻击通常依赖于浏览器会自动携带与目标站点相关的Cookie(包括身份验证的Cookie)的特性。
  • 在传统的基于Session的认证中,服务器会为每个用户创建一个Session,并返回一个Session ID(通常通过Cookie存储)。当用户发起请求时,浏览器会自动携带这个Cookie,服务器通过Session ID来识别用户。因此,如果用户已经登录,攻击者可以构造一个恶意请求,诱使用户点击,浏览器会自动携带Cookie,从而以用户身份执行操作。
  • 为了防止CSRF攻击,常见的做法是使用CSRF Token。服务器生成一个Token,通常放在表单的隐藏字段中(或者作为请求头),然后服务器验证请求中的Token是否与Session中存储的Token一致。因为攻击者无法获取到Token(由于浏览器的同源策略),所以无法构造出合法的请求。
  1. 关于JWT:
  • JWT(JSON Web Token)是一种无状态的认证机制。服务器在用户登录后生成一个Token(包含用户信息、过期时间等),并返回给客户端。客户端在后续请求中需要携带这个Token(通常放在Authorization头中)。服务器验证Token的签名和有效性,从而识别用户身份。
  • 由于JWT不需要服务器保存Session,因此被称为无状态的。每次请求都携带Token,服务器通过验证Token来确认用户身份,而不是通过Cookie中的Session ID。因此,在JWT模式下,通常不需要担心CSRF攻击,因为:
    • 浏览器不会自动在跨域请求中携带Authorization头(除非使用Credentialed请求,但即使这样,攻击者也无法构造出合法的Authorization头,因为他们不知道Token)。
    • 但是,如果Token是存储在Cookie中(而不是使用LocalStorage然后通过JS手动添加到请求头),那么仍然可能受到CSRF攻击。因为攻击者可以诱导用户发起一个请求,浏览器会自动携带Cookie,从而将Token发送到服务器。所以,如果使用Cookie存储JWT,仍然需要CSRF保护。
  1. 关于CORS:
  • CORS(跨域资源共享)是一种机制,它允许在浏览器中运行的脚本从不同的源(域名、协议、端口)访问资源。浏览器出于安全考虑,会限制跨域请求(如同源策略)。CORS通过设置一些HTTP头(如Access-Control-Allow-Origin)来告诉浏览器该资源允许被哪些源访问。
  • 在前后端分离的架构中,前端和后端通常部署在不同的源(比如不同的端口或域名),因此需要配置CORS以允许跨域请求。否则,浏览器会阻止跨域请求的响应数据。
  • 在Spring Security中,可以通过CorsFilter或者配置http.cors()来启用CORS支持。这样,服务器会在响应中添加必要的CORS头。
    我的理解:
  • 在传统的基于Session认证的web项目中,用户的信息会存在浏览器cookie里,CSRF是一种通过诱导用户点击使浏览器发送请求从而从cookie中获取用户的信息。
  • JWT是每次请求都需要在发送请求的请求头中携带表明身份(如用户名,有效时间等)的请求头来进行身份验证,通常是Authorization: Bearer 。
  • CORS跨域过滤器是为了避免通过JWT方式验证身份时遭遇跨域限制,通过HTTP响应头声明允许的跨域规则,浏览器先发OPTIONS请求检查CORS规则,通过后才发真实请求。

5、Spring Security 密码加密

在Spring Security 配置类SecurityConfig中,有一个bCryptPasswordEncoder()方法。

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

BCryptPasswordEncoder实现PasswordEncoder接口

java 复制代码
package org.springframework.security.crypto.password;

public interface PasswordEncoder {
    /**
    * 将原始密码加密为密文
    */
    String encode(CharSequence rawPassword);

    /**
    * 验证原始密码与加密密文是否匹配
    */
    boolean matches(CharSequence rawPassword, String encodedPassword);

    /**
    * 判断已加密密码是否需要重新加密(默认不需要)
    */
    default boolean upgradeEncoding(String encodedPassword) {
        return false;
    }
}

身份认证流程

在SecurityConfig中配置了AuthenticationManager bean,它使用了DaoAuthenticationProvider,并设置了UserDetailsService(即UserDetailsServiceImpl)和密码编码器bCryptPasswordEncoder()。

  • 在 SysLoginService 的login()方法中创建UsernamePasswordAuthenticationToken对象authenticationToken,把用户名,密码传入其中并set进AuthenticationContextHolder上下文,
  • 当SysLoginService的登录方法调用authenticationManager.authenticate(authenticationToken)时,实际调用的是ProviderManager(Spring Security默认的AuthenticationManager实现)
  • ProviderManager会遍历其配置的AuthenticationProvider列表(这里是DaoAuthenticationProvider)
  • DaoAuthenticationProvider会调用我们配置的UserDetailsService(即UserDetailsServiceImpl)的loadUserByUsername方法来加载用户信息
  • 加载到用户信息后,DaoAuthenticationProvider会使用配置的密码编码器(BCryptPasswordEncoder)来验证密码是否匹配
    将相关代码贴在这里
java 复制代码
/**
* SysLoginService的login()方法中身份验证部分
*/
// 用户验证
Authentication authentication = null;
try
{
    UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
    AuthenticationContextHolder.setContext(authenticationToken);
    // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
    authentication = authenticationManager.authenticate(authenticationToken);
}
catch (Exception e)
{
    if (e instanceof BadCredentialsException)
    {
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
        throw new UserPasswordNotMatchException();
    }
    else
    {
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
        throw new ServiceException(e.getMessage());
    }
}
finally
{
    AuthenticationContextHolder.clearContext();
}

/**
* SecurityConfig中的身份验证配置
*/
@Bean
public AuthenticationManager authenticationManager()
{
    DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
    daoAuthenticationProvider.setUserDetailsService(userDetailsService);
    daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
    return new ProviderManager(daoAuthenticationProvider);
}

6、Spring Security 退出配置

在SecurityConfig中有一行退出登录的配置,Spring Security提供了一个logout方法,这里设置一个拦截的url以及退出成功后的处理。

java 复制代码
logout(logout -> logout.logoutUrl("/logout").logoutSuccessHandler(logoutSuccessHandler))

我们来看一下这个logoutSuccessHandler

java 复制代码
package com.ruoyi.framework.security.handle;

import java.io.IOException;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler;
import com.alibaba.fastjson2.JSON;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.ServletUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.web.service.TokenService;

/**
 * 自定义退出处理类 返回成功
 * 
 * @author ruoyi
 */
@Configuration
public class LogoutSuccessHandlerImpl implements LogoutSuccessHandler
{
    @Autowired
    private TokenService tokenService;

    /**
     * 退出处理
     * 
     * @return
     */
    @Override
    public void onLogoutSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication)
            throws IOException, ServletException
    {
        // 从request拿到用户信息
        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, MessageUtils.message("user.logout.success")));
        }
        ServletUtils.renderString(response, JSON.toJSONString(AjaxResult.success(MessageUtils.message("user.logout.success"))));
    }
}

LogoutSuccessHandlerImpl实现了LogoutSuccessHandler,LogoutSuccessHandler是Spring Security默认启动的,这里重写了onLogoutSuccess()方法。

前端api下的login.js中的退出方法即访问的/logout url进行退出操作

js 复制代码
// 退出方法
export function logout() {
  return request({
    url: '/logout',
    method: 'post'
  })
}

7、Spring Security 登录配置

在SecurityConfig已经定义了身份认证实现,在4.3节我们已经查看了身份认证实现authenticationManager()方法以及自定义身份验证处理UserDetailsService。现在我们从登录接口来查看其登录配置。

首先查看SysLoginController中的login()方法

jaav 复制代码
/**
 * 登录方法
 * 
 * @param loginBody 登录信息
 * @return 结果
 */
@PostMapping("/login")
public AjaxResult login(@RequestBody LoginBody loginBody)
{
    AjaxResult ajax = AjaxResult.success();
    // 生成令牌
    String token = loginService.login(loginBody.getUsername(), loginBody.getPassword(), loginBody.getCode(),
            loginBody.getUuid());
    ajax.put(Constants.TOKEN, token);
    return ajax;
}

这里调用自定义的loginService.login()登录逻辑,我们来查看其自定义的登录服务

java 复制代码
package com.ruoyi.framework.web.service;

import javax.annotation.Resource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import com.ruoyi.common.constant.CacheConstants;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.constant.UserConstants;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.exception.user.BlackListException;
import com.ruoyi.common.exception.user.CaptchaException;
import com.ruoyi.common.exception.user.CaptchaExpireException;
import com.ruoyi.common.exception.user.UserNotExistsException;
import com.ruoyi.common.exception.user.UserPasswordNotMatchException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.MessageUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.common.utils.ip.IpUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.security.context.AuthenticationContextHolder;
import com.ruoyi.system.service.ISysConfigService;
import com.ruoyi.system.service.ISysUserService;

/**
 * 登录校验方法
 * 
 * @author ruoyi
 */
@Component
public class SysLoginService
{
    @Autowired
    private TokenService tokenService;

    @Resource
    private AuthenticationManager authenticationManager;

    @Autowired
    private RedisCache redisCache;
    
    @Autowired
    private ISysUserService userService;

    @Autowired
    private ISysConfigService configService;

    /**
     * 登录验证
     * 
     * @param username 用户名
     * @param password 密码
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public String login(String username, String password, String code, String uuid)
    {
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        Authentication authentication = null;
        try
        {
            // 用户身份验证
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            // 如果是密码错误,抛出UserPasswordNotMatchException()异常,其它错误抛出具体异常信息
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        // 清理AuthenticationContextHolder上下文
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        // 登录成功后的操作,更新ip,登录时间,记录登录日志
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        // 记录登录日志
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }

    /**
     * 校验验证码
     * 
     * @param username 用户名
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public void validateCaptcha(String username, String code, String uuid)
    {
        // 检查系统是否开启了验证码功能(从redis中查询配置)
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        if (captchaEnabled)
        {
            // 从redis中获取对应uuid的验证码
            String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
            String captcha = redisCache.getCacheObject(verifyKey);
            // redis中对应验证码为空,则抛出验证码过期异常
            if (captcha == null)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
                throw new CaptchaExpireException();
            }
            // 从redis中删除过期验证码的key
            redisCache.deleteObject(verifyKey);
            // 用户输入验证码与redis中对应验证码不一致,抛出对应异常
            if (!code.equalsIgnoreCase(captcha))
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
                throw new CaptchaException();
            }
        }
    }

    /**
     * 登录前置校验
     * @param username 用户名
     * @param password 用户密码
     */
    public void loginPreCheck(String username, String password)
    {
        // 用户名或密码为空 错误
        if (StringUtils.isEmpty(username) || StringUtils.isEmpty(password))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("not.null")));
            throw new UserNotExistsException();
        }
        // 密码如果不在指定范围内 错误
        if (password.length() < UserConstants.PASSWORD_MIN_LENGTH
                || password.length() > UserConstants.PASSWORD_MAX_LENGTH)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        }
        // 用户名不在指定范围内 错误
        if (username.length() < UserConstants.USERNAME_MIN_LENGTH
                || username.length() > UserConstants.USERNAME_MAX_LENGTH)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
            throw new UserPasswordNotMatchException();
        }
        // IP黑名单校验
        String blackStr = configService.selectConfigByKey("sys.login.blackIPList");
        if (IpUtils.isMatchedIp(blackStr, IpUtils.getIpAddr()))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("login.blocked")));
            throw new BlackListException();
        }
    }

    /**
     * 记录登录信息
     *
     * @param userId 用户ID
     */
    public void recordLoginInfo(Long userId)
    {
        SysUser sysUser = new SysUser();
        sysUser.setUserId(userId);
        sysUser.setLoginIp(IpUtils.getIpAddr());
        sysUser.setLoginDate(DateUtils.getNowDate());
        userService.updateUserProfile(sysUser);
    }
}

我们再看一下这里的AsyncManager,在这里是进行异步执行,这里的核心逻辑是进行用户登录验证,为了快速响应这里异步执行日志记录等辅助功能操作。既保证了主流程的高效执行,又完成了必要的辅助操作。

java 复制代码
package com.ruoyi.framework.manager;

import java.util.TimerTask;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import com.ruoyi.common.utils.Threads;
import com.ruoyi.common.utils.spring.SpringUtils;

/**
 * 异步任务管理器
 * 
 * @author ruoyi
 */
public class AsyncManager
{
    /**
     * 操作延迟10毫秒
     */
    private final int OPERATE_DELAY_TIME = 10;

    /**
     * 异步操作任务调度线程池
     */
    private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");

    /**
     * 单例模式
     */
    private AsyncManager(){}

    private static AsyncManager me = new AsyncManager();

    public static AsyncManager me()
    {
        return me;
    }

    /**
     * 执行任务
     * 
     * @param task 任务
     */
    public void execute(TimerTask task)
    {
        executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
    }

    /**
     * 停止任务线程池
     */
    public void shutdown()
    {
        Threads.shutdownAndAwaitTermination(executor);
    }
}

单例模式: AsyncManager.me() 返回 AsyncManager 的单例实例,保证线程安全。

延迟执行: execute() 方法将任务提交到调度线程池,延迟10毫秒执行

线程池管理:使用共享的ScheduledExecutorService线程池来控制并发执行的线程数量,避免创建过多线程导致资源耗尽,统一管理和监控异步任务

8、权限配置

在我们上述提到的UserDetailsServiceImpl实现类中有一个createLoginUser方法,当用户验证通过后来创建一个LoginUser对象,其中permissionService.getMenuPermission(user)即为获取该用户的权限。

java 复制代码
public UserDetails createLoginUser(SysUser user)
{
    return new LoginUser(user.getUserId(), user.getDeptId(), user, permissionService.getMenuPermission(user));
}

我们来看SysPermissionService的代码

java 复制代码
package com.ruoyi.framework.web.service;

import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import com.ruoyi.common.constant.UserConstants;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.system.service.ISysMenuService;
import com.ruoyi.system.service.ISysRoleService;

/**
 * 用户权限处理
 * 
 * @author ruoyi
 */
@Component
public class SysPermissionService
{
    @Autowired
    private ISysRoleService roleService;

    @Autowired
    private ISysMenuService menuService;

    /**
     * 获取角色数据权限
     * 
     * @param user 用户信息
     * @return 角色权限信息
     */
    public Set<String> getRolePermission(SysUser user)
    {
        Set<String> roles = new HashSet<String>();
        // 管理员拥有所有权限
        if (user.isAdmin())
        {
            roles.add("admin");
        }
        else
        {
            roles.addAll(roleService.selectRolePermissionByUserId(user.getUserId()));
        }
        return roles;
    }

    /**
     * 获取菜单数据权限
     * 
     * @param user 用户信息
     * @return 菜单权限信息
     */
    public Set<String> getMenuPermission(SysUser user)
    {
        Set<String> perms = new HashSet<String>();
        // 管理员拥有所有权限
        if (user.isAdmin())
        {
            perms.add("*:*:*");
        }
        else
        {
            // 获取用户的角色列表
            List<SysRole> roles = user.getRoles();
            if (!CollectionUtils.isEmpty(roles))
            {
                // 多角色设置permissions属性,以便数据权限匹配权限
                for (SysRole role : roles)
                {
                    // 如果角色状态正常且不是admin角色
                    if (StringUtils.equals(role.getStatus(), UserConstants.ROLE_NORMAL) && !role.isAdmin())
                    {
                        // 通过角色id从数据库中查询权限
                        Set<String> rolePerms = menuService.selectMenuPermsByRoleId(role.getRoleId());
                        role.setPermissions(rolePerms);
                        perms.addAll(rolePerms);
                    }
                }
            }
            // 如果没有角色则通过用户id从数据库中查询权限
            else
            {
                perms.addAll(menuService.selectMenuPermsByUserId(user.getUserId()));
            }
        }
        return perms;
    }
}

这里我们看一下这两个查询权限的sql及结果

xml 复制代码
<select id="selectMenuPermsByRoleId" parameterType="Long" resultType="String">
    select distinct m.perms
    from sys_menu m
        left join sys_role_menu rm on m.menu_id = rm.menu_id
    where m.status = '0' and rm.role_id = #{roleId}
</select>

<select id="selectMenuPermsByUserId" parameterType="Long" resultType="String">
    select distinct m.perms
    from sys_menu m
        left join sys_role_menu rm on m.menu_id = rm.menu_id
        left join sys_user_role ur on rm.role_id = ur.role_id
        left join sys_role r on r.role_id = ur.role_id
    where m.status = '0' and r.status = '0' and ur.user_id = #{userId}
</select>

9、权限注解

在ruoyi-framework模块中com.ruoyi.framework.web.service下有一个PermissionService,来判断是否具有权限等。

java 复制代码
package com.ruoyi.framework.web.service;

import java.util.Set;
import org.springframework.stereotype.Service;
import org.springframework.util.CollectionUtils;
import com.ruoyi.common.constant.Constants;
import com.ruoyi.common.core.domain.entity.SysRole;
import com.ruoyi.common.core.domain.model.LoginUser;
import com.ruoyi.common.utils.SecurityUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.security.context.PermissionContextHolder;

/**
 * RuoYi首创 自定义权限实现,ss取自SpringSecurity首字母
 * 
 * @author ruoyi
 */
@Service("ss")
public class PermissionService
{
    /**
     * 验证用户是否具备某权限,有权限返回true,没权限返回false
     * 
     * @param permission 权限字符串
     * @return 用户是否具备某权限
     */
    public boolean hasPermi(String permission)
    {
        if (StringUtils.isEmpty(permission))
        {
            return false;
        }
        // 获取用户信息
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
        {
            return false;
        }
        // 将当前检查权限set进上下文
        PermissionContextHolder.setContext(permission);
        // 返回是否具备该权限
        return hasPermissions(loginUser.getPermissions(), permission);
    }

    /**
     * 验证用户是否不具备某权限,与 hasPermi逻辑相反
     *
     * @param permission 权限字符串
     * @return 用户是否不具备某权限
     */
    public boolean lacksPermi(String permission)
    {
        return hasPermi(permission) != true;
    }

    /**
     * 验证用户是否具有以下任意一个权限
     *
     * @param permissions 以 PERMISSION_DELIMETER 为分隔符的权限列表
     * @return 用户是否具有以下任意一个权限
     */
    public boolean hasAnyPermi(String permissions)
    {
        if (StringUtils.isEmpty(permissions))
        {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getPermissions()))
        {
            return false;
        }
        PermissionContextHolder.setContext(permissions);
        Set<String> authorities = loginUser.getPermissions();
        // 通过","分割permissions权限字符串,循环判断是否有该权限
        for (String permission : permissions.split(Constants.PERMISSION_DELIMETER))
        {
            if (permission != null && hasPermissions(authorities, permission))
            {
                return true;
            }
        }
        return false;
    }

    /**
     * 判断用户是否拥有某个角色
     * 
     * @param role 角色字符串
     * @return 用户是否具备某角色
     */
    public boolean hasRole(String role)
    {
        if (StringUtils.isEmpty(role))
        {
            return false;
        }
        // 获取用户信息
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
        {
            return false;
        }
        // 遍历该用户的角色列表,判断是否包括该角色
        for (SysRole sysRole : loginUser.getUser().getRoles())
        {
            String roleKey = sysRole.getRoleKey();
            // 是否是管理员角色或包括该角色
            if (Constants.SUPER_ADMIN.equals(roleKey) || roleKey.equals(StringUtils.trim(role)))
            {
                return true;
            }
        }
        return false;
    }

    /**
     * 验证用户是否不具备某角色,与 isRole逻辑相反。
     *
     * @param role 角色名称
     * @return 用户是否不具备某角色
     */
    public boolean lacksRole(String role)
    {
        return hasRole(role) != true;
    }

    /**
     * 验证用户是否具有以下任意一个角色
     *
     * @param roles 以 ROLE_NAMES_DELIMETER 为分隔符的角色列表
     * @return 用户是否具有以下任意一个角色
     */
    public boolean hasAnyRoles(String roles)
    {
        if (StringUtils.isEmpty(roles))
        {
            return false;
        }
        LoginUser loginUser = SecurityUtils.getLoginUser();
        if (StringUtils.isNull(loginUser) || CollectionUtils.isEmpty(loginUser.getUser().getRoles()))
        {
            return false;
        }
        for (String role : roles.split(Constants.ROLE_DELIMETER))
        {
            if (hasRole(role))
            {
                return true;
            }
        }
        return false;
    }

    /**
     * 判断是否包含权限
     * 
     * @param permissions 权限列表
     * @param permission 权限字符串
     * @return 用户是否具备某权限
     */
    private boolean hasPermissions(Set<String> permissions, String permission)
    {
        // 返回用户的权限列表中是否包含所有权限标识符或该权限标识符
        return permissions.contains(Constants.ALL_PERMISSION) || permissions.contains(StringUtils.trim(permission));
    }
}

其中PermissionService通过@Service注册到容器中并命名"ss",可以通过 @ss 来引用这个权限服务,用于进行权限判断和控制。set 为不允许重复的字符串集合,查询效率更高。

下面我们看一下如何应用PermissionService服务,查看ruoyi-admin模块com.ruoyi.web.controller.system下的SysUserController的部分代码。

java 复制代码
/**
 * 获取用户列表
 */
 // 直接@ss.hasPermi进行调用,传入权限字符串,判断是否有获取用户列表权限
@PreAuthorize("@ss.hasPermi('system:user:list')")
@GetMapping("/list")
public TableDataInfo list(SysUser user)
{
    startPage();
    List<SysUser> list = userService.selectUserList(user);
    return getDataTable(list);
}

@Log(title = "用户管理", businessType = BusinessType.EXPORT)
@PreAuthorize("@ss.hasPermi('system:user:export')")
@PostMapping("/export")
public void export(HttpServletResponse response, SysUser user)
{
    List<SysUser> list = userService.selectUserList(user);
    ExcelUtil<SysUser> util = new ExcelUtil<SysUser>(SysUser.class);
    util.exportExcel(response, list, "用户数据");
}

@PreAuthorize 是 Spring Security 框架提供的一个方法级安全注解,用于在方法执行前进行权限检查。

  • 作用:在方法执行前验证用户是否具有指定权限,如果没有相应权限则拒绝访问
  • 位置:可以放在类或方法上,方法上的注解优先级更高

在这里的具体作用为

  • @ss 引用之前注册的名为"ss"的PermissionService Bean
  • .hasPermi('system:user:list') 调用该服务的hasPermi方法检查用户是否具有"system:user:list"权限
  • 只有当权限检查通过时,才会执行被注解的方法list(SysUser user)
相关推荐
AOwhisky8 小时前
Redis 学习笔记(第三期):持久化与主从复制
运维·数据库·redis·笔记·学习·云计算
GoGeekBaird8 小时前
从 Prompt Engineering 到 Loop Engineering,我觉得 AI 开发这事儿终于开始变味了
后端·github
问心无愧05138 小时前
ctf show web入门160 161
前端·笔记
一条泥憨鱼8 小时前
【Redis】数据类型和常用命令
java·数据库·redis·后端·缓存
云烟成雨TD9 小时前
Spring AI Alibaba 1.x 系列【78】沙箱(Sandbox)
java·人工智能·spring
Oneslide9 小时前
初始化微信小程序
后端
Tbisnic9 小时前
AI大模型学习第十一天:技术选型、安全防护与金融实战
python·学习·ai·大模型·提示词工程
Flying_Fish_roe9 小时前
springcloud-Eureka的原理
spring·spring cloud·eureka
hboot10 小时前
AI工程师第一课 - Python
前端·后端·python
阿正的梦工坊10 小时前
【Rust】12-借用检查器与非词法生命周期
开发语言·后端·rust