Artículos prácticos de Spring Security

prefacio

Como primer artículo, este artículo usará ejemplos para ilustrar el uso de Spring Security en producción y expandir sus funciones. Cada solución tendrá un código de ejemplo completo, y el almacén de códigos se publicará al final del artículo.

La teoría involucrada en este artículo es menor, principalmente con ejemplos.

Versión de memoria (memoria)

Esta versión no tiene tecnología y se puede utilizar introduciendo dependencias.

importación directa

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
  1. Después de introducir las dependencias, puede iniciar el proyecto directamente
  2. Se generará una contraseña aleatoria en la consola y el nombre de usuario predeterminado esuser

Usando la contraseña de seguridad generada: 2233be62-e65f-489b-a52c-4bba21bcfd14

  1. establecer contraseña de usuario

    1. Puede configurarse a través de application.yml, o inyectarse a través de config, obviamente es más conveniente usar yml

      spring:
        security:
          # 配置默认的 InMemoryUserDetailsManager 的用户账号与密码。
          user:
            name: ali
            password: 123456
            roles: admin
      

      imagen-20230529212637658

    2. Hay dos formas de escribir directamente a través del código, y las dos también se pueden mezclar

      Método 1: inyección directaInMemoryUserDetailsManager

      @Configuration
      @EnableWebSecurity
      public class SecurityWebCofnig extends WebSecurityConfigurerAdapter {
              
              
      
          @Bean
          public PasswordEncoder passwordEncoder() {
              
              
              return new BCryptPasswordEncoder();
          }
      
          @Override
          protected void configure(AuthenticationManagerBuilder auth) throws Exception {
              
              
              auth.inMemoryAuthentication()
                  .withUser("ali")
                  .password(passwordEncoder().encode("123456"))
                  .roles("admin");
          }
      }
      

      Método 2: método orientado a la interfaz

         
      @Configuration
      @EnableWebSecurity
      public class SecurityWebCofnig extends WebSecurityConfigurerAdapter {
              
              
          @Bean
          public PasswordEncoder passwordEncoder() {
              
              
              return new BCryptPasswordEncoder();
          }
      
          @Autowired
          private UserDetailsService userDetailsService;
      
          @Override
          protected void configure(AuthenticationManagerBuilder auth) throws Exception {
              
              
              auth.inMemoryAuthentication()
                  .withUser("ali")
                  .password(passwordEncoder().encode("123456"))
                  .roles("admin");
              auth.userDetailsService(userDetailsService);
          }
      }
      
      @Component
      public class CustomUserServiceImpl implements UserDetailsService {
              
              
      
          @Autowired
          private PasswordEncoder passwordEncoder;
      
          @Override
          public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
              
              
              return new User("ali2", passwordEncoder.encode("123456"), AuthorityUtils.commaSeparatedStringToAuthorityList("admin"));
          }
      }
      

Versión de la base de datos (jdbc)

Esta versión, basada en la versión de memoria, ha mejorado la función de verificación del usuario, extendiéndose desde el usuario integrado hasta la base de datos, y también es una versión relativamente limitada.

Luego, el enfoque aquí es el userDetailServicemétodo de implementación, UserDetailsolo devuelva una subclase, y otra autenticación de inicio de sesión y redirección se realizan por seguridad.

     <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.4.0</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
@Data
@TableName("role")
public class Role implements Serializable {
    
    
    private static final long serialVersionUID = -27787294406430777L;

    @TableField("id")
    private Integer id;

    @TableField("name")
    private String name;

    @TableField("code")
    private String code;

    @TableField("status")
    private Integer status;

    @TableField("deleted")
    private Integer deleted;

}
@Data
@TableName("user")
public class SysUser {
    
    

    private String id;

    private String username;

    private String realName;

    private String password;

    @TableField(exist = false)
    private String[] roles;
}

@Data
@TableName("user_role")
public class UserRole implements Serializable {
    
    
    private static final long serialVersionUID = 180339547857105479L;

    @TableField("id")
    private Integer id;

    @TableField("role_id")
    private Integer roleId;

    @TableField("user_id")
    private Integer userId;
}

public interface SysUserService {
    
    

    SysUser getByUsername(String username);
}

@Service
@AllArgsConstructor
public class SysUserServiceImpl implements SysUserService {
    
    

    private SysUserRepository sysUserRepository;
    private UserRoleRepository userRoleRepository;
    private RoleRepository roleRepository;
    @Override
    public SysUser getByUsername(String username) {
    
    
        SysUser user = sysUserRepository.getByUsername(username);
        if (user == null) {
    
    
            return null;
        }
        List<UserRole> roles = userRoleRepository.getByUserId(user.getId());
        if (roles.isEmpty()) {
    
    
            return user;
        }
        List<Integer> roleIds = roles.stream().map(UserRole::getRoleId).collect(Collectors.toList());
        user.setRoles(roleRepository.listByIds(roleIds).stream().map(Role::getCode).collect(Collectors.toList()));
        return user;
    }
}

Método de consulta de seguridad definido por el usuario

@Component
@AllArgsConstructor
public class CustomUserServiceImpl implements UserDetailsService {
    
    
    private SysUserService sysUserService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
    
    
        SysUser sysUser = sysUserService.getByUsername(username);
        if (sysUser == null) {
    
    
            return null;
        }
        return new User(sysUser.getUsername(), sysUser.getPassword(), AuthorityUtils.createAuthorityList(sysUser.getRoles()));
    }
}

Al inyectar aquí, se retienen los usuarios de la versión de memoria.

@Configuration
@EnableWebSecurity
public class SecurityWebConfig extends WebSecurityConfigurerAdapter {
    
    

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    @Autowired
    private UserDetailsService userDetailsService;

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        auth.inMemoryAuthentication()
            .withUser("ali")
            .password(passwordEncoder().encode("123456"))
            .roles("admin");
        auth.userDetailsService(userDetailsService);
    }
}

Yo uso Mybatis plus, agregue mapperScan aquí

@MapperScan("com.liry.security.repository.mapper")
@SpringBootApplication
public class JdbcApp {
    
    
    public static void main(String[] args) {
    
    
        SpringApplication.run(JdbcApp.class);
    }
}


aplicación.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/ali?useSSl=false&zeroDateTimeBehavior=CONVERT_TO_NULL&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
    hikari:
      minimum-idle: 5
      idle-timeout: 600000
      maximum-pool-size: 10
      auto-commit: true
      pool-name: SsoHikariCP
      max-lifetime: 1800000
      connection-timeout: 30000
      connection-test-query: SELECT 1
  main:
    allow-bean-definition-overriding: true


mybatis-plus:
  global-config:
    db-config:
      logic-delete-field: deleted  # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
      logic-delete-value: 1 # 逻辑已删除值(默认为 1)
      logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
      id-type: AUTO
  configuration:
    # 日志打印
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

Inicio de sesión personalizado - Único (custom-login-single)

Esta versión actualiza la función personalizada de inicio de sesión en la versión de la base de datos, y puede configurar su propia página de inicio de sesión, página de inicio, API de procesamiento de inicio de sesión y procesamiento de devolución de llamada para inicios de sesión exitosos y fallidos según el proyecto;

Digresión: la ventaja de no separar el anverso y el reverso es un desarrollo rápido y una implementación fácil, pero sus desventajas también son obvias. Con el proyecto iterativo, se volverá cada vez más grande. La corriente principal todavía está distribuida, y los proyectos individuales son raros ahora.

Así que aquí están las cosas a tener en cuenta:

  1. Después de introducir el marco frontal, debe configurarse en application.yml (yo uso themleaf aquí)
  2. La clase de herencia WebSecurityConfigurerAdaptercompleta la configuración personalizada
    1. Use la configuración para una página de inicio de sesión exitosa defaultSuccessUrl, no usesuccessForwardUrl
    2. Puede definir un posprocesador exitoso y un procesador fallido, estos dos serán llamados después de la autenticación y puede hacer una expansión comercial
  3. La configuración loginProcessingUrlno es para transferir la solicitud de procesamiento a la interfaz que definió, sino para modificar la dirección API dentro de la seguridad.

Aviso:

loginProcessingUrl: la api para el procesamiento de inicio de sesión, esto es solo para definir la api, no para cambiar la autenticación a personalizada o para ser controlado por seguridad

defaultSuccessUrl: La dirección de salto después de un inicio de sesión exitoso, hay otra successForwardUrl, pero solo se puede usar defaultSuccessUrl, y successForwardUrlaparecerá un error 405 al usarla

permitAll: permitir todos los métodos de solicitud

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-thymeleaf</artifactId>
        </dependency>
    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        http
            .formLogin()
            // 自定义登录页
            .loginPage("/login.html")
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 不能写:successForwardUrl("/index.html"),会报405
            .defaultSuccessUrl("/index.html")
            // 登录失败转发到哪个页面
            .failureForwardUrl("/login.html?error=true")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            // 开启认证
            .and().authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护
    }

Devolución de llamada de error de inicio de sesión

public class LoginFailureHandler implements AuthenticationFailureHandler {
    
    
    @Override
    public void onAuthenticationFailure(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                        AuthenticationException e) throws IOException, ServletException {
    
    
        // 处理失败的后置操作
    }
}

Devolución de llamada de inicio de sesión exitosa

public class LoginSuccessHandler implements AuthenticationSuccessHandler {
    
    
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                        Authentication authentication) throws IOException, ServletException {
    
    
        // 处理成功的一个后置操作
    }
}

controlador de prueba

@Controller
public class LoginController {
    
    

    @GetMapping("/login.html")
    public String loginPage() {
    
    
        return "login";
    }

    @GetMapping("/index.html")
    public String index() {
    
    
        return "index";
    }
}

Página de inicio de sesión probada (login.html)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>登录</title>
</head>
<body>
    <div>
        <!-- 这里action对应的是在-->
        <form action="/loginDeal" method="POST" >
            用户名<input placeholder="输入用户名" name="username"></br>
            密  码<input placeholder="输入密码" name="pwd" type="password"></br>
            <button type="submit">登录</button>
        </form>
    </div>
</body>
</html>

página de inicio de prueba

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>首页</title>
</head>
<body>
<h2>首页</h2>
</body>
</html>

Inicio de sesión personalizado: parte delantera y trasera separadas

A diferencia de un solo proyecto, la escalabilidad de la separación de adelante hacia atrás aumenta considerablemente, lo que significa que enfrentará más problemas, como la consistencia entre dominios y sesiones.

  1. Personaliza el método CustomUserServiceImplde carga de UserDetailsServicela información del usuario

  2. Método de procesamiento de devolución de llamada de autenticación personalizado LoginSuccessHandler/LoginFailureHandler(inicio de sesión exitoso, almacenar información de usuario, serialización de respuesta)

  3. Crear una CacheManagercaché unificada de interfaz de caché

  4. heredarWebSecurityConfigurerAdapter

    Configuración:

    corsFilter(跨域)

    userDetailService(用户信息)

    passwordEncoder(加解密)

    securityContextRepository(认证信息管理)

    AuthenticationEntryPoint(响应序列号)

  5. Hay muchas maneras de lidiar con la consistencia de la sesión. Diferentes personas tienen diferentes opiniones. Hay ventajas y desventajas. Redis se usa aquí. Si no hay necesidad de un entorno grande, también son posibles un front-end y un back-end.

Gestión de caché, redis utilizada aquí;

@Configuration
public class RedisConfig {
    
    

    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
    
    
        RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        return redisTemplate;
    }

}
/**
 * @author ALI
 * @since 2023/6/4
 */
@Component
public class CacheManager {
    
    
    private static final int TIME_OUT = 4;
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    public Object get(String key) {
    
    
        return redisTemplate.opsForValue().get(key);
    }

    public <T> T get(String key, Class<T> clazz) {
    
    
        Object value = get(key);
        if (value == null) {
    
    
            return null;
        }
        return (T) value;
    }

    public void set(String key, Object value) {
    
    
        if (value == null) {
    
    
            redisTemplate.delete(key);
            return;
        }
        redisTemplate.opsForValue().set(key, value, TIME_OUT, TimeUnit.HOURS);
    }

    public void set(String key, Object value, Long timeOut, TimeUnit timeUnit) {
    
    
        if (value == null) {
    
    
            redisTemplate.delete(key);
            return;
        }
        redisTemplate.opsForValue().set(key, value, timeOut, timeUnit);
    }

    public boolean containsKey(String key) {
    
    
        return Boolean.TRUE.equals(redisTemplate.hasKey(key));
    }

    public long getExpire(String key) {
    
    
        Long expire = redisTemplate.getExpire(key);
        if (expire == null) {
    
    
            return 0L;
        }
        return expire;
    }
}

/**
 * 认证的常量
 * @author ALI
 * @since 2023/6/10
 */
public class AuthConstant {
    
    

    public static final String LOGIN_PRE = "login:";
    public static final String CAPTCHA_PRE = "captcha:";

    public static String buildLoginKey(String key) {
    
    
        return LOGIN_PRE + key;
    }

    public static String buildCaptchaKey(String key) {
    
    
        return CAPTCHA_PRE + key;
    }
}

@Data
public class CustomUser implements UserDetails {
    
    

    private static final long serialVersionUID = 5469888959861441262L;

    protected String userId;

    protected String password;

    protected String username;

    protected Collection<? extends GrantedAuthority> authorities;

    public CustomUser() {
    
    

    }

    public CustomUser(SysUser sysUser) {
    
    
        this.userId = sysUser.getId();
        this.username = sysUser.getUsername();
        this.password = sysUser.getPassword();
        if (!CollectionUtils.isEmpty(sysUser.getRoles())) {
    
    
            this.authorities = sysUser.getRoles().stream().map(d -> new SimpleGrantedAuthority("ROLE_" + d)).collect(Collectors.toList());
        }
    }

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

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

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

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

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

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

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

}

objeto de respuesta

/**
 * 返回结果对象
 *
 * @author 李瑞益
 * @since 2019/9/25
 */
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class ResponseData<T> implements Serializable {
    
    

    public static final String SUCCESS = "success";
    public static final String FAILED = "failed";

    private static final long serialVersionUID = -4304353934293881342L;

    /** 处理结果 */
    private boolean status;

    /** 信息 */
    private String message;

    /** 状态编码 */
    private String code;

    /** 数据对象 */
    private T data;

    public ResponseData(boolean status, T data) {
    
    
        this.status = status;
        this.data = data;
        this.code = status ? SUCCESS : FAILED;
    }

    public ResponseData(boolean status, T data, String message) {
    
    
        this.status = status;
        this.data = data;
        this.message = message;
        this.code = status ? SUCCESS : FAILED;
    }

    public ResponseData(Throwable e) {
    
    
        this.status = false;
        this.message = e.getMessage();
        this.code = FAILED;
    }

    public static ResponseData<Object> ok() {
    
    
        return new ResponseData<>(true, null);
    }


    public static ResponseData<Object> failed() {
    
    
        return new ResponseData<>(false, null);
    }

    public static ResponseData<Object> failed(Throwable e) {
    
    
        return new ResponseData<>(false, null, e.getMessage());
    }

    public static ResponseData<Object> failed(String message) {
    
    
        return new ResponseData<>(false, null, message);
    }
}

/**
 * 主要用来做响应体序列化
 * @author ALI
 * @since 2023/6/4
 */
public class CustomizeAuthenticationEntryPoint implements AuthenticationEntryPoint {
    
    
    @Override
    public void commence(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, AuthenticationException e) throws IOException, ServletException {
    
    
        httpServletResponse.setContentType("application/json;charset=utf-8");
        httpServletResponse.setStatus(HttpStatus.UNAUTHORIZED.value());
        ResponseData<Object> result = ResponseData.failed(e.getMessage());
        PrintWriter out = httpServletResponse.getWriter();
        out.write(JSON.toJSONString(result));
        out.flush();
        out.close();
    }
}

Tenga en cuenta que:

Nuestros códigos anteriores son todos servicios basados ​​en una sola arquitectura, por lo que al configurar SecurityWebConfig, configuraremos páginas de inicio de sesión, páginas de error, etc., pero estas cosas ya no son necesarias después de separar el anverso y el reverso.

imagen-20230608230929174

Esquema de consistencia de sesión

Cuando se trata del esquema de consistencia de la sesión, aquí hay tres esquemas que se desarrollan gradualmente.

opcion uno

Utilice la memoria caché redis directamente en el método de devolución de llamada (inicio de sesión personalizado)

Esta solución utiliza el procesador de devolución de llamada de inicio de sesión exitoso en seguridad para establecer la información del usuario en redis, luego agrega un interceptor de encabezado de solicitud para interceptar el token en el encabezado de la solicitud, obtiene la información del usuario en redis a través del token y luego lo establece en el contexto de seguridad SecurityContextHolderEso está bien; la desventaja de esta solución es que mantendrá dos cachés, y la cantidad de código también es grande

  1. Personaliza el método CustomUserServiceImplde carga de UserDetailsServicela información del usuario
  2. Método de procesamiento de devolución de llamada de autenticación personalizado LoginSuccessHandler/LoginFailureHandler(inicio de sesión exitoso, almacenar información de usuario, serialización de respuesta)
  3. Crear una CacheManagercaché unificada de interfaz de caché
  4. CustomizeAuthenticationEntryPointImplementación personalizada del AuthenticationEntryPointmétodo de serialización.
  5. CustomHeaderAuthFilterHerencia personalizada BasicAuthenticationFilterpara completar el procesamiento del token de encabezado de solicitud
  6. LoginSuccessHandlerAgregar lógica de almacenamiento en caché del usuario
  7. Heredar WebSecurityConfigurerAdapterconfiguración inicio de sesión personalizado

Aviso:

  1. Si lo usa , no lo establezca al BasicAuthenticationFilterconfigurar , o no pasará por nuestro filtro personalizadoWebSecurityConfigurerAdapterhttp.httpBasic()

  2. Este método estará separado de la seguridad. En la personalización CustomHeaderAuthFilter, debe juzgar la API de inicio de sesión e ignorar la API para evitar ser interceptado por usted mismo.

LoginSuccessHandler: después de que el usuario inicie sesión correctamente, configure la información del usuario en el caché y serialice json en el front-end

/**
 * 登录成功处理器
 * 序列化处理
 *
 * @author ALI
 * @since 2023/6/1
 */
public class LoginSuccessHandler implements AuthenticationSuccessHandler {
    
    

    private CacheManager cacheManager;

    public LoginSuccessHandler(CacheManager cacheManager) {
    
    
        this.cacheManager = cacheManager;
    }
    @Override
    public void onAuthenticationSuccess(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                        Authentication authentication) throws IOException, ServletException {
    
    
        // 将成功后的会话id设置到响应,以便在链里的过滤器能够拿到
        String token = UUID.randomUUID().toString();
        httpServletResponse.setHeader(HttpHeaders.AUTHORIZATION, token);
        // 设置响应的格式
        httpServletResponse.setContentType("application/json;charset=utf-8");
        ResponseData<CustomUser> result = new ResponseData<>();
        CustomUser user = (CustomUser) authentication.getPrincipal();
        cacheManager.set(AuthConstant.buildLoginKey(token), user);

        UserView temp = new UserView(user, token);
        result.setData(temp);
        PrintWriter writer = httpServletResponse.getWriter();
        writer.write(JSON.toJSONString(result));
        writer.flush();
    }
}

CustomHeaderAuthFilter: un filtro de encabezado de solicitud personalizado que intercepta el encabezado del token en el encabezado de la solicitud. Esto es equivalente a nuestra propia iniciativa para autenticar. Luego, este filtro debe estar antes del filtro. Después de la autenticación, debe pasar la autenticación. La información se establece UsernamePasswordAuthenticationFilteren el contexto de seguridad, y debido a que nuestro filtro es un poco independiente de la seguridad, necesitamos sincronizar la SecurityWebConfigAPI ignorada configurada en él;

Cabe señalar que todos ellos try catchse capturan aquí, por lo que cualquier excepción ExceptionTranslationFilterserá manejada por

/**
 * 主要用来拦截token的
 * <p>
 * 这里构造器我需要注入authenticationManager ,但是这个类在SecurityConfig里注入,所有我只有在用到的地方手动注入
 *
 * @author ALI
 * @since 2023/6/4
 */
public class CustomHeaderAuthFilter extends BasicAuthenticationFilter {
    
    

    private CacheManager cacheManager;

    public CustomHeaderAuthFilter(AuthenticationManager authenticationManager,
                                  AuthenticationEntryPoint authenticationEntryPoint, CacheManager cacheManager) {
    
    
        super(authenticationManager, authenticationEntryPoint);
        this.cacheManager = cacheManager;
    }

    private void doParse(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    throws IOException, ServletException {
    
    
        String header = request.getHeader(HttpHeaders.AUTHORIZATION);
        try {
    
    
            // 这个步骤是将redis的信息设置到security上下文
            if (header.startsWith("Bearer")) {
    
    
                String token = header.replace("Bearer ", "");
                CustomUser user = cacheManager.get(AuthConstant.buildLoginKey(token), CustomUser.class);
                if (user == null) {
    
    
                    throw new AccountExpiredException("token无效!");
                }
                UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(user, user.getPassword(), user.getAuthorities());
                // 设置的上下文
                SecurityContextHolder.getContext().setAuthentication(authToken);
            }
        } catch (Exception e) {
    
    
            // 抛出 AuthenticationException AccessDeniedException 两个类型的异常给 ExceptionTranslationFilter
            throw new AccountExpiredException("登录失败!");
        }
        chain.doFilter(request, response);
    }

    @Override
    protected void doFilterInternal(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse,
                                    FilterChain filterChain) throws ServletException, IOException {
    
    
        String authorization = httpServletRequest.getHeader(HttpHeaders.AUTHORIZATION);
        // 无token,和登录的走默认的逻辑
        // 还有被忽略的api
        if (httpServletRequest.getRequestURI().contains("/login") || httpServletRequest.getRequestURI().contains("/test")) {
    
    
            filterChain.doFilter(httpServletRequest, httpServletResponse);
            return;
        }
        this.doParse(httpServletRequest, httpServletResponse, filterChain);
    }
}

SecurityWebConfig: Aquí está la configuración de seguridad, cabe señalar que no olvide inyectar la clase personalizada y configurarla

/**
 * security认证配置
 *
 * @author ALI
 * @since 2023/5/29
 */
@Configuration
@EnableWebSecurity
public class SecurityWebConfig extends WebSecurityConfigurerAdapter {
    
    

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private CacheManager cacheManager;
    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    @Bean
    public org.springframework.web.filter.CorsFilter corsFilter() {
    
    
        return new CorsFilter(corsConfigurationSource());
    }

    /**
     * 跨域设置
     */
    private CorsConfigurationSource corsConfigurationSource() {
    
    
        org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // 允许cookies跨域
        config.setAllowCredentials(true);
        // 允许向该服务器提交请求的URI,* 表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
        config.addAllowedOrigin("*");
        // 允许访问的头信息,* 表示全部
        config.addAllowedHeader("*");
        // 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
        config.setMaxAge(18000L);
        // 允许提交请求的方法,* 表示全部允许
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        auth.inMemoryAuthentication()
            .withUser("ali")
            .password(passwordEncoder().encode("123456"))
            .roles("admin");
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
// 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter

        http
            .formLogin()
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler(cacheManager))
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            // 开启认证
            .and().authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护

        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint());

        http.addFilterBefore(
            new CustomHeaderAuthFilter(authenticationManager(), new CustomizeAuthenticationEntryPoint(), cacheManager),
            UsernamePasswordAuthenticationFilter.class);
    }
}

imagen-20230605223036913

imagen-20230605223058424

Opción II

Vuelva a escribir la clase de proveedor de caché dentro de Spring Security SecurityContextRepository(custom-login2)

Esta solución parte de la perspectiva del marco de código fuente, y la ejecución de reemplazo beanha logrado que el acceso final a la información del usuario sea el mismo espacio de caché.Es muy simple en principio, pero requiere algunos conocimientos de código fuente y los requisitos comerciales. son cambiables Aunque admite securityla expansión, pero el costo de aprendizaje no es bajo, por lo que debe mirar el proyecto desde una perspectiva global, pero es muy cómodo de usar y la cantidad de código es muy pequeña, si desea Comprueba el uso interno, puedes comprobarlo SecurityContextPersistenceFilter.

  1. Personaliza el método CustomUserServiceImplde carga de UserDetailsServicela información del usuario
  2. Método de procesamiento de devolución de llamada de autenticación personalizada LoginSuccessHandler/LoginFailureHandler(serialización de respuesta)
  3. Crear una CacheManagercaché unificada de interfaz de caché
  4. CustomizeAuthenticationEntryPointImplementación personalizada del AuthenticationEntryPointmétodo de serialización.
  5. CustomSecurityContextRepositoryLa herencia personalizada SecurityContextRepositoryanula sus 3 métodos de acceso a la información
  6. Heredar WebSecurityConfigurerAdapterconfiguración inicio de sesión personalizado
/**
 * 自定义的session存储器
 *
 * @author ALI
 * @since 2023/6/4
 */
@Component
public class CustomSecurityContextRepository implements SecurityContextRepository {
    
    

    @Autowired
    private CacheManager cacheManager;

    @Override
    public SecurityContext loadContext(HttpRequestResponseHolder requestResponseHolder) {
    
    
        HttpServletRequest request = requestResponseHolder.getRequest();
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isBlank(token)) {
    
    
            return generateNewContext();
        }
        token = token.replace("Bearer ", "");
        SecurityContextImpl s = cacheManager.get(AuthConstant.buildLoginKey(token), SecurityContextImpl.class);
        if (s == null) {
    
    
            return generateNewContext();
        }
        return s;
    }

    @Override
    public void saveContext(SecurityContext context, HttpServletRequest request, HttpServletResponse response) {
    
    
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isBlank(token)) {
    
    
            token = response.getHeader(HttpHeaders.AUTHORIZATION);
        }
        if (StringUtils.isBlank(token)) {
    
    
            return;
        }
        token = token.replace("Bearer ", "");
        // 登录成功和失败的回调(LoginSuccessHandler,LoginFailureHandler)是在UsernamePasswordAuthenticationFilter过滤器里执行的
        // 而这里的认证信息缓存是在`SecurityContextPersistenceFilter`的doFilter后执行的
        // `SecurityContextPersistenceFilter`的顺序比`UsernamePasswordAuthenticationFilter`的顺序小,
        // 那么doFilter之后的方法就晚与LoginSuccessHandler,LoginFailureHandler
        cacheManager.set(AuthConstant.buildLoginKey(token), context);
    }

    @Override
    public boolean containsContext(HttpServletRequest request) {
    
    
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (StringUtils.isBlank(token)) {
    
    
            return false;
        }
        token = token.replace("Bearer ", "");
        return cacheManager.containsKey(AuthConstant.buildLoginKey(token));
    }

    protected SecurityContext generateNewContext() {
    
    
        return SecurityContextHolder.createEmptyContext();
    }

}

configuración de seguridad

@Configuration
@EnableWebSecurity
public class SecurityWebConfig extends WebSecurityConfigurerAdapter {
    
    

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private CustomSecurityContextRepository securityContextRepository;

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    @Bean
    public org.springframework.web.filter.CorsFilter corsFilter() {
    
    
        return new CorsFilter(corsConfigurationSource());
    }

    /**
     * 跨域设置
     */
    private CorsConfigurationSource corsConfigurationSource() {
    
    
        org.springframework.web.cors.UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // 允许cookies跨域
        config.setAllowCredentials(true);
        // 允许向该服务器提交请求的URI,* 表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
        config.addAllowedOrigin("*");
        // 允许访问的头信息,* 表示全部
        config.addAllowedHeader("*");
        // 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
        config.setMaxAge(18000L);
        // 允许提交请求的方法,* 表示全部允许
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        auth.inMemoryAuthentication()
            .withUser("ali")
            .password(passwordEncoder().encode("123456"))
            .roles("admin");
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            .formLogin()
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            // 开启认证
            .and().authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint());
    }
}

¿Por qué usar esto: http.setSharedObject?

Mire SecurityContextConfigurerla clase de configuración, obtendrá http.getSharedObject(SecurityContextRepository.class)la clase de implementación correspondiente, que Map implementa internamente, por lo que cuando configuramos http.setSharedObjectnuestro repositorio personalizado, podemos reescribirlo; de manera predeterminada, http.getSharedObject(SecurityContextRepository.class) = nullse new HttpSessionSecurityContextRepositoryestablece directamente enSecurityContextPersistenceFilter

imagen-20230618104650675

tercera solucion

uso Spring session(inicio de sesión personalizado3)

Esta solución utiliza componentes de primavera spring session, que también es una primavera nativa, y también está dirigida a la gestión de sesiones; nuevamente, diferentes personas tienen diferentes opiniones.

        <dependency>
            <groupId>org.springframework.session</groupId>
            <artifactId>spring-session-data-redis</artifactId>
        </dependency>

y luego anotar@SpringBootApplication

Entonces está hecho. Este método ahorra el mayor esfuerzo. Después de todo, este es un componente producido por Spring. Es una actualización funcional para Spring Framework. Este método no requiere que configure tokens y otras cosas.

imagen-20230606214039967

¿Por qué no usar JWT?

De hecho, hay otra forma de resolver la consistencia de la sesión, pero realmente no apoyo esta forma, es decir, se JWTdefine como un inicio de sesión sin estado, y su significado principal es que la JWTinformación generada es la información de inicio de sesión, por lo que mientras Como con esta información, los datos de inicio de sesión se pueden JWTobtener a través de esta información.

Es muy bueno, pero hay algunas cosas que no me gustan mucho:

  1. Hay una parte de la información generada por JWT que se puede revertir para extraer información del usuario;
  2. JWT no puede caducar activamente, por lo que no se puede iniciar en el verdadero sentido, luego, para lograr la caducidad, debe almacenarse en caché, entonces esto es lo mismo que la segunda solución, pero la segunda solución no necesita escribir el descifrado, verificar la legitimidad de el JWT, y no necesita ir Adaptar la seguridad;
  3. Esto es similar a lo anterior, es decir, si la empresa requiere restricciones de inicio de sesión, como solo un inicio de sesión con la misma cuenta, JWT aún debe almacenarse en caché;

Los 3 puntos anteriores son suficientes para que no admita el uso de JWT.

Cambiar inicio de sesión a inicio de sesión json (inicio de sesión personalizado-json)

El inicio de sesión predeterminado de seguridad es all POST + form-data. Si usa json, no puede obtener los parámetros, pero los proyectos front-end generalmente realizan un procesamiento de intercepción unificado. Por supuesto, también puede dejar que el front-end cambie el inicio de sesión a formData request.

Hay varios puntos a tener en cuenta sobre este inicio de sesión:

  1. La autenticación de inicio de sesión predeterminada UsernamePasswordAuthenticationFilterse realiza mediante , su valor no está en formato json, por lo que reescribimos su método de autenticación attemptAuthentication;

  2. Establezca nuestra UsernamePasswordAuthenticationFilterconfiguración personalizada en HttpSecurity, porque es un reemplazo, por lo que debemos UsernamePasswordAuthenticationFiltercopiar la configuración relevante, como el nombre del parámetro de inicio de sesión, la API de procesamiento de inicio de sesión, el procesador de éxito de inicio de sesión y el procesador de fallas ( esto es muy importante );

  3. PasswordEncoderLa coincidencia de contraseñas consiste en pasar contraseñas sin cifrar (pasadas por el front-end) y cifradas (guardadas por el back-end), por lo que si la contraseña está cifrada, debe descifrarse aquí;

Personaliza UsernamePasswordAuthenticationFilterla autenticación de json

/**
 * 自定义json认证
 * @author ALI
 * @since 2023/6/7
 */
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
    

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
    
        try (InputStream inputStream = request.getInputStream()) {
    
    
            ObjectMapper objectMapper = new ObjectMapper();
            Map<String, String> loginRequest = objectMapper.readValue(inputStream, Map.class);
            String username = loginRequest.get(super.getUsernameParameter());
            String password = loginRequest.get(super.getPasswordParameter());
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            return this.getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
    
    
            throw new RuntimeException(e);
        }
    }
}

Agregar configuración personalizadaHttpSecurity

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            // 开启认证
            .authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint());

        // 将我们自定义过滤器加入到原来UsernamePasswordAuthenticationFilter的前面
        http.addFilterBefore(customUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

    }

    /**
     * 自定义的customUsernamePasswordAuthenticationFilter
     * 需要同步在HttpSecurity里的配置
     */
    public CustomUsernamePasswordAuthenticationFilter customUsernamePasswordAuthenticationFilter() throws Exception {
    
    
        CustomUsernamePasswordAuthenticationFilter result = new CustomUsernamePasswordAuthenticationFilter();
        result.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(loginApi));
        result.setAuthenticationManager(this.authenticationManager());
        result.setUsernameParameter(usernameParameter);
        result.setPasswordParameter(passwordParameter);
        result.setAuthenticationSuccessHandler(new LoginSuccessHandler());
        result.setAuthenticationFailureHandler(new LoginFailureHandler());
        return result;
    }

Entonces addFilterBeforeeste método nos lo proporciona la seguridad para expandirnos, si personalizamos uno UsernamePasswordAuthenticationFiltery lo ponemos al frente, después de que se pase nuestra autenticación, no seguiremos pasando por los siguientes filtros, y nuestra cobertura está completa.

imagen-20230608232743758

certificado

Aquí, el proyecto custom-login-json se usa como proyecto básico.

Porque si desea atacar su sitio web, la primera posibilidad es intentar descifrar la contraseña. Si su transmisión no es segura, será interceptada, por lo que debe encriptarla. Además, si el atacante usa fuerza bruta para atacar, la contraseña requiere Debe ser complicado, aumentar el rango de agotamiento del atacante, reducir la tasa de aciertos y aumentar la cantidad de fallas de la cuenta, así como el límite de ip. Al mismo tiempo, también puede configurar el código de verificación para aumentar la dificultad de craqueo.

Inicio de sesión cifrado con contraseña (autorización personalizada)

La seguridad de la red también es algo que nuestros programadores deben considerar, así que aquí hacemos un cifrado y descifrado de contraseñas, aquí usamos el segundo esquema (custom-login2) como proyecto básico, pero el siguiente esquema no se limita a un determinado esquema, sino Es aplicable a todos los elementos de seguridad.

herramienta de cifrado

Aquí se utiliza el algoritmo de cifrado asimétrico RSA, y tanto el cifrado como el descifrado requieren el uso de claves públicas y secretas, lo cual es muy seguro.

/**
 * rsa加密工具简化版
 *
 * @author ALI
 * @date 2021-09-25 15:44
 */
public class RsaUtil {
    
    

    private static final String RSA_ALGORITHM = "RSA";
    private static final String AES_ALGORITHM = "AES";

    private RsaUtil() {
    
    
    }

    /**
     * 生成密钥对
     *
     * @param passKey 关键密码
     * @return 密钥对
     */
    public static KeyPair genratorKeyPair(String passKey) throws NoSuchAlgorithmException {
    
    
        KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(RSA_ALGORITHM);
        SecureRandom secureRandom = new SecureRandom(passKey.getBytes());
        secureRandom.setSeed(passKey.getBytes());
        keyPairGenerator.initialize(2048, secureRandom);
        return keyPairGenerator.generateKeyPair();
    }

    /**
     * 加密密码
     *
     * @param password  密码
     * @param publicKey 公钥
     * @return 加密后的密文
     */
    public static byte[] encrypt(PublicKey publicKey, byte[] password) {
    
    
        try {
    
    
            Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
            cipher.init(Cipher.ENCRYPT_MODE, publicKey);
            return cipher.doFinal(password);
        } catch (Exception e) {
    
    
            throw new RuntimeException("RSA加密失败(RSA encrypt failed.)");
        }
    }

    /**
     * 解密密码
     *
     * @param encryptPassword 加密的密码
     * @param privateKey      私钥
     * @return 解密后的明文
     */
    public static byte[] decrypt(PrivateKey privateKey, byte[] encryptPassword) {
    
    
        try {
    
    
            Cipher cipher = Cipher.getInstance(RSA_ALGORITHM);
            cipher.init(Cipher.DECRYPT_MODE, privateKey);
            return cipher.doFinal(encryptPassword);
        } catch (Exception e) {
    
    
            throw new RuntimeException("RSA解密失败(RSA encrypt failed.)");
        }
    }

    /**
     * 密钥Base64
     *
     * @param privateKey 密钥
     * @return 结果
     */
    public static String getPrivateBase64(PrivateKey privateKey) {
    
    
        return Base64.getEncoder().encodeToString(privateKey.getEncoded());
    }

    /**
     * 公钥Base64
     *
     * @param publicKey 公钥
     * @return 结果
     */
    public static String getPublicBase64(PublicKey publicKey) {
    
    
        return Base64.getEncoder().encodeToString(publicKey.getEncoded());
    }

    /**
     * 根据公钥字符串获取公钥对象
     *
     * @param publicKeyString 公钥字符串
     * @return 结果
     */
    public static PublicKey getPublicKey(String publicKeyString)
    throws NoSuchAlgorithmException, InvalidKeySpecException {
    
    
        byte[] decode = Base64.getDecoder().decode(publicKeyString);
        return KeyFactory.getInstance(RSA_ALGORITHM).generatePublic(new X509EncodedKeySpec(decode));
    }

    /**
     * 根据密钥字符串获取密钥对象
     *
     * @param privateKeyString 密钥字符串
     * @return 结果
     */
    public static PrivateKey getPrivateKey(String privateKeyString)
    throws NoSuchAlgorithmException, InvalidKeySpecException {
    
    
        byte[] decode = Base64.getDecoder().decode(privateKeyString);
        return KeyFactory.getInstance(RSA_ALGORITHM).generatePrivate(new PKCS8EncodedKeySpec(decode));
    }

    /**
     * 对称加密AES 对称key生成
     *
     * @param passKey 关键密码
     * @return 生成aes的key
     * @throws NoSuchAlgorithmException 算法找不到异常
     */
    public static SecretKey aesKey(String passKey) throws NoSuchAlgorithmException {
    
    
        KeyGenerator keyGenerator = KeyGenerator.getInstance(AES_ALGORITHM);
        SecureRandom secureRandom = new SecureRandom();
        secureRandom.setSeed(passKey.getBytes());
        keyGenerator.init(secureRandom);
        return keyGenerator.generateKey();
    }

    /**
     * @param mode      加解密模式:Cipher.ENCRYPT_MODE / Cipher.DECRYPT_MODE
     * @param secretKey 对称key
     * @param password  执行的密码
     */
    public static byte[] aes(int mode, SecretKey secretKey, byte[] password) {
    
    
        try {
    
    
            Cipher instance = Cipher.getInstance(AES_ALGORITHM);
            instance.init(mode, secretKey);
            return instance.doFinal(password);
        } catch (Exception e) {
    
    
            throw new RuntimeException(String.format("AES执行失败,Cipher.mode:%s(AES encrypt failed.)", mode));
        }
    }

    public static void main(String[] args) throws Exception {
    
    

        String passkey = "dd";
        KeyPair dd = genratorKeyPair(passkey);
        String pu = new String(Base64.getEncoder().encode(dd.getPublic().getEncoded()));
        String en = new String(Base64.getEncoder().encode(dd.getPrivate().getEncoded()));
        System.out.println("publicKey:\n" + pu);
        System.out.println("private:\n" + en);

        // 加解密方案1:RSA + AES双重加密
        // 1. AES加密
        SecretKey key = aesKey(passkey);
        byte[] aesEn = aes(Cipher.ENCRYPT_MODE, key, "123456".getBytes());
        // 2. 通过RSA公钥加密密码
        byte[] rsaEn = encrypt(dd.getPublic(), aesEn);
        // 3. 通过RSA私钥解密密码
        byte[] rsaDe = decrypt(dd.getPrivate(), rsaEn);
        // 4. 再同AES解密
        byte[] aesDe = aes(Cipher.DECRYPT_MODE, key, rsaDe);
        System.out.println("两重解密:" + new String(aesDe));

        // 加解密方案2:RSA加密
        byte[] encrypt = encrypt(RsaUtil.getPublicKey(pu), "123456".getBytes());
        byte[] decrypt = decrypt(RsaUtil.getPrivateKey(en), encrypt);
        System.out.println("RSA解密:" + new String(decrypt));
    }
}

generar par de claves

Ejecute RsaUtilel método principal, genere publicKeyy privateKey, y luego privateKeyguárdelo en el backend y publicKeydéselo al frontend, y luego, cuando el frontend pasa la contraseña al backend, primero pasa RSAy publicKeyencripta;

imagen-20230609212038780

Ponga la clave privada en el archivo: privateKey.pem

, use pemo dersufije el almacenamiento de archivos de formato, de lo contrario habrá problemas en el análisis

imagen-20230609223545679

leer clave privada

SecurityWebConfigAgregue un constructor para leer la clave privada durante la inicialización (este método se puede usar para empaquetar y se puede usar con confianza);

privateKey se establece en público y se puede llamar en cualquier parte del proyecto.

    public static PrivateKey privateKey;
	
    public  SecurityWebConfig() throws Exception {
    
    
        try(InputStream is = this.getClass().getClassLoader().getResourceAsStream("privateKey.pem")) {
    
    
            if (is == null) {
    
    
                throw  new RuntimeException("没有读取的密钥!!!");
            }
            byte[] data = new byte[2048];
            int length = is.read(data);
            String privateKeyString = new String(data, 0, length);
            privateKey = RsaUtil.getPrivateKey(privateKeyString.trim());
        }
    }

descifrado de filtros

CustomUsernamePasswordAuthenticationFilterDescifre la contraseña en la personalizada y luego authenticationTokenconfigúrela, porque el último método de verificación matchesdebe hacer coincidir la contraseña antes del cifrado y la contraseña después del cifrado;

@Slf4j
public class CustomUsernamePasswordAuthenticationFilter extends UsernamePasswordAuthenticationFilter {
    
    

    private static String decondePassword(String password) throws NoSuchAlgorithmException, InvalidKeySpecException {
    
    
        byte[] decode = Base64.getDecoder().decode(password.getBytes());
        return new String(RsaUtil.decrypt(SecurityWebConfig.privateKey, decode));
    }

    @Override
    public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response) throws AuthenticationException {
    
    
        try (InputStream inputStream = request.getInputStream()) {
    
    
            ObjectMapper objectMapper = new ObjectMapper();
            Map<String, String> loginRequest = objectMapper.readValue(inputStream, Map.class);
            String username = loginRequest.get(super.getUsernameParameter());
            String password = loginRequest.get(super.getPasswordParameter());
            try {
    
    
                password = decondePassword(password);
            } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
    
    
                log.error("密码解密失败!!!!", e);
                password = null;
            }
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            // 使用 AuthenticationManager 进行身份验证
            return this.getAuthenticationManager().authenticate(authenticationToken);
        } catch (IOException e) {
    
    
            throw new RuntimeException(e);
        }
    }
}

Por supuesto, el proceso de descifrado también se puede descifrar en el lugar que realmente coincide, y el org.springframework.security.authentication.dao.DaoAuthenticationProvider#additionalAuthenticationChecksmétodo de reescritura tiene la misma lógica, por lo que no es necesario escribir código.

imagen-20230618121936190

Agregar filtro personalizado a la seguridad

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            .formLogin()
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            // 开启认证
            .and().authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint());

        
        // 自定义过滤器加入security
        http.addFilterBefore(customUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    }

Configuración de front-end e inicio de sesión

Aquí use el proyecto de plantilla (), después de bajar, instale la herramienta de cifrado

npm instalar jsencrypt

instalar npm

  1. Modifique axios.jsel final Axios.interceptors.response.a lo siguiente:

    Axios.interceptors.response.use((res) => {
          
          
      if (res.config.direct) {
          
          
        return res.data
      }
      return Promise.resolve(res)
    }, (error) => {
          
          
      Message.error(error.response.data)
      return Promise.reject(error.response.data)
    })
    
  2. Añadir método de inicio de sesión

    export function login (data, success, error) {
          
          
      http.post('/loginDeal', data, success, error)
    }
    
    
  3. Configurar la clave en una variable de entorno

    Aquí solo se requiere configuración dev.env.jsin y prod.env.jsin, dev es el entorno de desarrollo y prod es el entorno de producción.

    Nota: el valor de la variable aquí está marcado con una comilla doble y una comilla simple, de modo que cuando se use, no se reemplazará webpackdirectamente en el momento de la compilación y causará un error de sintaxis.

    module.exports = merge(prodEnv, {
          
          
      NODE_ENV: '"development"',
      PUBLIC_KEY: '"MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnQRntjLw0wC4xPki/Tvf+7esQwf2PoCmvb8oKypvssevr8LK74CF/Yh0AjMvmoAlr0UXm5VK4B2edmvLwLTeFjAU8zXdNLlhC7YDUpc/vEZFPhh2jvUMjOe0LAJb+FOv5oMGpAxuj8PC9Cz4L05T/gOI7w8FPwCJjXJacWPhhSAK+dViXHLZVqNeIo4YRUT8C2s5e+vz03FByd511YaydVTbBGRB7+QVFJ5f6Rt9buxn9gDK5CcZ27ScQvdc88w9NF0bfmNRh8xec3Cz9uMyRVhy5d3pJM9a6jTEHcbOTapUAjssq2cVr+qx5DGv87u4I8qKqJQIhvu40Vd3foR0JQIDAQAB"'
    })
    
    

    imagen-20230610160724135

  4. Crear login.vue(simplemente lo configuré aquí)

    Para el formulario de inicio de sesión, separo el campo de contraseña ingresado por el usuario del campo de contraseña transmitido al backend, para mejorar la experiencia del usuario.

    <template>
      <div>
        <el-row>
          <el-col :span="6">
            <el-form :model="form" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
              <el-form-item label="用户名" prop="username">
                <el-input type="text" v-model="form.username"></el-input>
              </el-form-item>
              <el-form-item label="密码" prop="password">
                <el-input type="password" v-model="form.password"></el-input>
              </el-form-item>
              <el-form-item>
                <el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
              </el-form-item>
            </el-form>
          </el-col>
        </el-row>
      </div>
    </template>
    
    <script>
    import {loginApi} from '../api'
    import {JSEncrypt} from 'jsencrypt'
    export default {
      name: 'login',
      data () {
        return {
          form: {
            username: '',
            password: '',
            pwd: ''
          },
          rules: {
            password: [
              {required: true, trigger: 'blur', message: '请输入密码'}
            ],
            username: [
              {required: true, trigger: 'blur', message: '请输入用户名'}
            ]
          }
        }
      },
      methods: {
        submitForm (formName) {
          this.$refs[formName].validate((valid) => {
            if (valid) {
              // 加密密码
              this.form.pwd = this.encryptedData(process.env.PUBLIC_KEY, this.form.password)
              this.form.password = null
              loginApi.login(this.form, success => {
                this.$message.success('登录成功')
                // 存储token信息,和用户信息
                // 跳转指定页面
                console.log(success)
              })
            } else {
              this.$message.info('表单验证失败')
              return false
            }
          })
        },
        // 加密密码
        encryptedData (publicKey, data) {
          // 新建JSEncrypt对象
          let encryptor = new JSEncrypt()
          // 设置公钥
          encryptor.setPublicKey(publicKey)
          // 加密数据
          return encryptor.encrypt(data)
        }
      }
    }
    </script>
    
    <style scoped>
    
    </style>
    
    
  5. route/index.jsAgregar enrutamiento

        {
          
          
          path: '/login',
          name: 'login',
          component: login
        }
    
  6. npm run dev

  7. navegadorhttp://localhost:8080/#/login

    imagen-20230609213618235

resultado

imagen-20230610175318500

Inicio de sesión de Captcha (autorización personalizada-captcha)

Continúe para completar el ejemplo anterior de cifrado y descifrado.

El principio es que el backend genera aleatoriamente una serie de números, los guarda y luego genera una imagen para el frontend, o aleatoriza una fórmula de cálculo simple, como: luego calcula el valor, lo guarda y luego genera una imagen 1+8=para la interfaz con esta fórmula. El foco está en el antes y el después. Cómo conectar, lo uso aquí sessionId.

Tenga en cuenta que el código de verificación no se puede usar todo el tiempo, y la aleatoriedad del código de verificación debe proporcionarse a través del tiempo y las estrategias de verificación.

generar código de verificación

Las herramientas utilizadas por la herramienta captcha aquí Hutool,

    <dependency>
            <groupId>cn.hutool</groupId>
            <artifactId>hutool-captcha</artifactId>
            <version>5.8.15</version>
        </dependency>

Aquí inyecto la herramienta generada por el algoritmo como singleton, y luego la uso durante la verificación, y luego el código de verificación caduca en 2 minutos

/**
 * 认证
 *
 * @author ALI
 * @since 2023/6/10
 */
@Service
public class AuthServiceImpl implements AuthService {
    
    

    @Autowired
    private CacheManager cacheManager;

    @Autowired
    private MathGenerator mathGenerator;

    @Override
    public void captcha(HttpServletRequest request, HttpServletResponse response) {
    
    
        ShearCaptcha captcha = CaptchaUtil.createShearCaptcha(200, 45, 4, 4);
        // 自定义验证码内容为四则运算方式
        captcha.setGenerator(mathGenerator);
        // 重新生成code
        captcha.createCode();
        String code = captcha.getCode();
        String id = request.getSession().getId();
        cacheManager.set(AuthConstant.buildCaptchaKey(id), code, 2L, TimeUnit.MINUTES);
        try (ServletOutputStream os = response.getOutputStream()) {
    
    
            // 这里通过base64加密再返回给前端,前端就不用处理了
            os.write(Base64.getEncoder().encode(captcha.getImageBytes()));
        } catch (IOException e) {
    
    
            throw new RuntimeException("验证码异常!");
        }
    }
}

No utilicé la herramienta hutool directamente aquí captcha.write(os);, porque si hago esto, escribirá el flujo de bytes directamente en el front-end, y luego el front-end necesita procesarlo nuevamente, así que aquí obtengo el byte, lo encripto nuevamente , y Base64luego Escrito para el front-end, de modo que el front-end solo necesita deletrear uno antes del resultado data:image/png;base64,para usarlo.

Proporcionar interfaz de código de verificación

    @GetMapping("/login/captcha")
    public String captcha(HttpServletRequest request, HttpServletResponse response) {
    
    
        authService.captcha(request, response);
        return null;
    }

imagen-20230610151104259

Crear filtro captcha

Personalice un filtro y agréguelo a la cadena de intercepción de seguridad.

El filtro de código de verificación personalizado aquí, heredado OncePerRequestFilter, solo irá una vez, por supuesto, solo necesita ir una vez y eliminar la clave después de que la verificación sea exitosa;

/**
 * 自定义的验证码过滤器
 *
 * @author ALI
 * @since 2023/6/10
 */
public class CustomCaptchaFilter extends OncePerRequestFilter {
    
    


    public String loginApi;
    private CacheManager cacheManager;
    private MathGenerator mathGenerator;
    private AuthenticationEntryPoint entryPoint;

    public CustomCaptchaFilter(CacheManager cacheManager, MathGenerator mathGenerator, AuthenticationEntryPoint authenticationEntryPoint,
                               String loginApi) {
    
    
        this.cacheManager = cacheManager;
        this.mathGenerator = mathGenerator;
        this.entryPoint = authenticationEntryPoint;
        this.loginApi = loginApi;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
    throws ServletException, IOException {
    
    
        if (request.getRequestURI().equals(loginApi)) {
    
    
            String id = request.getSession().getId();
            String key = AuthConstant.buildCaptchaKey(id);
            Object match = cacheManager.get(key);
            if (match == null) {
    
    
                entryPoint.commence(request, response, new BadCredentialsException("验证码过期!"));
                return;
            }
            // 注意这里的验证码参数不是从body里获取的
            if (!mathGenerator.verify(match.toString(), request.getParameter(AuthConstant.CAPTCHA))) {
    
    
                entryPoint.commence(request, response, new BadCredentialsException("验证码验证错误!"));
                return;
            }
             // 验证成功删除
            cacheManager.delete(key);
        }
        filterChain.doFilter(request, response);
    }
}

Agregar filtro a la seguridad

@Configuration
@EnableWebSecurity
public class SecurityWebConfig extends WebSecurityConfigurerAdapter {
    
    
    private static final String loginApi = "/loginDeal";
    private static final String usernameParameter = "username";
    private static final String passwordParameter = "pwd";

    public static PrivateKey privateKey;

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private CustomSecurityContextRepository securityContextRepository;
    @Autowired
    private CacheManager cacheManager;

    public SecurityWebConfig() throws Exception {
    
    
        try (InputStream is = this.getClass().getClassLoader().getResourceAsStream("privateKey.pem")) {
    
    
            if (is == null) {
    
    
                throw new RuntimeException("没有读取的密钥!!!");
            }
            byte[] data = new byte[2048];
            int length = is.read(data);
            String privateKeyString = new String(data, 0, length);
            privateKey = RsaUtil.getPrivateKey(privateKeyString.trim());
        }
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsFilter corsFilter() {
    
    
        return new CorsFilter(corsConfigurationSource());
    }

    // 注入算法生成工具
    @Bean
    public MathGenerator mathGenerator() {
    
    
        return new MathGenerator(1);
    }

    /**
     * 自定义的customUsernamePasswordAuthenticationFilter
     * 需要同步在HttpSecurity里的配置
     */
    public CustomUsernamePasswordAuthenticationFilter customUsernamePasswordAuthenticationFilter() throws Exception {
    
    
        CustomUsernamePasswordAuthenticationFilter result = new CustomUsernamePasswordAuthenticationFilter();
        result.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(loginApi));
        result.setAuthenticationManager(this.authenticationManager());
        result.setUsernameParameter(usernameParameter);
        result.setPasswordParameter(passwordParameter);
        result.setAuthenticationSuccessHandler(new LoginSuccessHandler());
        result.setAuthenticationFailureHandler(new LoginFailureHandler());
        return result;
    }

    /**
     * 跨域设置
     */
    private CorsConfigurationSource corsConfigurationSource() {
    
    
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // 允许cookies跨域
        config.setAllowCredentials(true);
        // 允许向该服务器提交请求的URI,* 表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
        config.addAllowedOrigin("*");
        // 允许访问的头信息,* 表示全部
        config.addAllowedHeader("*");
        // 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
        config.setMaxAge(18000L);
        // 允许提交请求的方法,* 表示全部允许
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        auth.inMemoryAuthentication()
            .withUser("ali")
            .password(passwordEncoder().encode("123456"))
            .roles("admin");
        auth.userDetailsService(userDetailsService);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            .formLogin()
            // 自定义登录页
            .loginPage("/login.html")
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 不能写:successForwardUrl("/index.html"),会报405
            .defaultSuccessUrl("/index.html")
            // 登录失败转发到哪个页面
            .failureForwardUrl("/login.html?error=true")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            // 开启认证
            .and().authorizeRequests()
            //设置哪些路径可以直接访问,不需要认证
            .antMatchers("/test/*", "/login/**").permitAll()
            //需要认证
            .anyRequest().authenticated()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint());

        http.addFilterBefore(customUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        // 添加验证码过滤器
        http.addFilterBefore(new CustomCaptchaFilter(cacheManager, mathGenerator(), entryPoint, loginApi), WebAsyncManagerIntegrationFilter.class);
    }
}

Agregué el filtro del código de verificación aquí al WebAsyncManagerIntegrationFilterfrente. Es el primer filtro. La parte de verificación del código de verificación es la primera, lo cual es muy razonable. Al mismo tiempo, al CustomizeAuthenticationEntryPointescribir directamente, ya no seguirá la lógica de seguridad.

Código de verificación de la pantalla frontal (login2.vue)

<template>
  <div>
    <el-row>
      <el-col :span="6">
        <el-form :model="form" status-icon :rules="rules" ref="ruleForm" label-width="100px" class="demo-ruleForm">
          <el-form-item label="用户名" prop="username">
            <el-input type="text" v-model="form.username"></el-input>
          </el-form-item>
          <el-form-item label="密码" prop="password">
            <el-input type="password" v-model="form.password"></el-input>
          </el-form-item>
          <el-form-item label="验证码" prop="captcha">
            <el-input type="text" v-model="form.captcha"></el-input>
            <el-row>
              <el-col :span="12" ><img :src="captchaImage" alt="0"/></el-col>
              <el-col :span="12" ><a href="javascript:void(0)" @click="renewCaptcha">看不清,换一张</a></el-col>
            </el-row>
          </el-form-item>
          <el-form-item>
            <el-button type="primary" @click="submitForm('ruleForm')">提交</el-button>
          </el-form-item>
        </el-form>
      </el-col>
    </el-row>
  </div>
</template>

<script>
import {
    
    loginApi} from '../api'
import {
    
    JSEncrypt} from 'jsencrypt'
export default {
    
    
  name: 'login2',
  data () {
    
    
    return {
    
    
      form: {
    
    
        username: '',
        password: '',
        pwd: '',
        captcha: ''
      },
      captchaImage: null,
      rules: {
    
    
        password: [
          {
    
    required: true, trigger: 'blur', message: '请输入密码'}
        ],
        username: [
          {
    
    required: true, trigger: 'blur', message: '请输入用户名'}
        ],
        captcha: [
          {
    
    required: true, trigger: 'blur', message: '请输入验证码'}
        ]
      }
    }
  },
  mounted () {
    
    
    this.getCaptchaPic()
  },
  methods: {
    
    
    submitForm (formName) {
    
    
      this.$refs[formName].validate((valid) => {
    
    
        if (valid) {
    
    
          // 加密密码
          this.form.pwd = this.encryptedData(process.env.PUBLIC_KEY.toString(), this.form.password)
          this.form.password = null
          loginApi.login(this.form, success => {
    
    
            this.$message.success('登录成功')
            // 存储token信息,和用户信息
            // 跳转指定页面
            console.log(success)
          }, error => {
    
    
            // 验证码过期就刷新验证码
            if (error.message.indexOf('过期') > 0) {
    
    
              this.getCaptchaPic()
            }
          })
        } else {
    
    
          this.$message.info('表单验证失败')
          return false
        }
      })
    },
    // 加密密码
    encryptedData (publicKey, data) {
    
    
      // 新建JSEncrypt对象
      let encryptor = new JSEncrypt()
      // 设置公钥
      encryptor.setPublicKey(publicKey)
      // 加密数据
      return encryptor.encrypt(data)
    },
    getCaptchaPic () {
    
    
      loginApi.captcha(null, success => {
    
    
        this.captchaImage = 'data:image/png;base64,' + success.data
      })
    },
    // 验证码刷新
    renewCaptcha () {
    
    
      this.getCaptchaPic()
    }
  }
}
</script>

<style scoped>

</style>

Coloque el código de verificación en la URL aquí, para que el backend pueda obtener el código de verificación directamente

export function login (data, success, error) {
    
    
  http.post('/loginDeal?captcha=' + data.captcha, data, success, error)
}

resultado

imagen-20230610174815817

Código de verificación de interfaz

En este caso, debe verificar el código de verificación al iniciar sesión.

extremo posterior

 @GetMapping("/login/captcha/valid")
    public Boolean validCaptcha(HttpServletRequest request) {
    
    
        return authService.validCaptcha(request);
    }

Lo mismo aquí, elimine el código de verificación después de que la verificación sea exitosa;

   @Override
    public boolean validCaptcha(HttpServletRequest request) {
    
    
        String id = request.getSession().getId();
        String key = AuthConstant.buildCaptchaKey(id);
        Object match = cacheManager.get(key);
        if (match == null) {
    
    
            throw new RuntimeException("验证码过期!");
        }
        // 注意这里的验证码参数不是从body里获取的
        boolean captcha = mathGenerator.verify(match.toString(), request.getParameter("captcha"));
        if (captcha) {
    
    
            cacheManager.delete(key);
        }
        return captcha;
    }

Interfaz (login3.vue)

export function validCaptcha (data, success, error) {
    
    
  http.get('/login/captcha/valid?captcha=' + data, null, success, error)
}

Para iniciar sesión aquí, primero verifique el código de verificación y luego llame al inicio de sesión después de tener éxito. Hay miles de métodos, y estoy dando un ejemplo aquí.

   submitForm (formName) {
    
    
      this.$refs[formName].validate((valid) => {
    
    
        if (valid) {
    
    
          // 加密密码
          this.form.pwd = this.encryptedData(process.env.PUBLIC_KEY.toString(), this.form.password)
          this.form.password = null
          this.validCaptchaAndLogin(this.form.captcha)
        } else {
    
    
          this.$message.info('表单验证失败')
          return false
        }
      })
    },
    validCaptchaAndLogin (captcha) {
    
    
      loginApi.validCaptcha(captcha, success => {
    
    
        loginApi.login(this.form, success => {
    
    
          this.$message.success('登录成功')
          // 存储token信息,和用户信息
          // 跳转指定页面
          console.log(success)
        }, error => {
    
    
          // 验证码过期就刷新验证码
          if (error.message.indexOf('过期') > 0) {
    
    
            this.getCaptchaPic()
          }
        })
      }, error => {
    
    
        console.log(error)
        this.getCaptchaPic()
      })
    }

Autorización/Control de acceso (custom-auth-control)

Serialización de respuestas para permisos

Del mismo modo CustomizeAuthenticationEntryPoint, se requiere la serialización de la configuración.

/**
 * 访问拒绝处理器
 * @author ALI
 * @since 2023/6/11
 */
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
    
    
    private AuthenticationEntryPoint entryPoint;
    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, AccessDeniedException accessDeniedException)
    throws IOException, ServletException {
    
    
        response.setContentType("application/json;charset=utf-8");
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);

        ResponseData<Object> result = ResponseData.failed("无权限访问");
        PrintWriter writer = response.getWriter();
        writer.write(JSON.toJSONString(result));
        writer.flush();
    }
}

SecurityWebConfigCambio de configuracion

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());

Configuración de permisos

Al guardar la información del rol en nuestra tabla en seguridad, necesitamos hacer algún procesamiento. El siguiente es el constructor para personalizar la información del usuario. Aquí, se agrega un prefijo al código del rol del usuario. Este es el método predeterminado de el marco de seguridad ROLE_.

    public CustomUser(SysUser sysUser) {
    
    
        this.userId = sysUser.getId();
        this.username = sysUser.getUsername();
        this.password = sysUser.getPassword();
        if (!CollectionUtils.isEmpty(sysUser.getRoles())) {
    
    
            this.authorities = sysUser.getRoles().stream().map(d -> new SimpleGrantedAuthority("ROLE_" + d)).collect(Collectors.toList());
        }
    }

Habilitar la interceptación de anotaciones

@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)
public class SecurityAuthConfig {
    
    

}

autoridad de ruta

WebSecurityConfigurerAdapterEsto se logra a través de la herencia void configure(HttpSecurity http) throws Exception;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            .formLogin()
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler());

        http.addFilterBefore(customUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.authorizeRequests()
            // 路径/test /login多级的都可以放行
            .antMatchers("/test/*", "/login/**").permitAll()
            // 访问getData2 需要角色dev
            .antMatchers("/getData2").hasRole("dev")
             // 访问getData3 需要角色 admin
            .antMatchers("/getData3").hasRole("admin")
             // 访问getData4 需要角色dev test1
            .antMatchers("/getData4").hasAnyRole("test1","dev")
             // 访问getData5 需要角色test1
            .antMatchers("/getData5").hasAuthority("ROLE_test1")
             // 访问getData6 需要为127.0.0.1
            .antMatchers("/getData6").hasIpAddress("127.0.0.1")
             // 访问getData7 需要角色test2 test3
            .antMatchers("/getData7").hasAnyAuthority("ROLE_test2", "ROLE_test3")
            .anyRequest().authenticated();
    }

En seguridad, el rol tendrá un prefijo predeterminado , por lo que se debe omitir el prefijo ROLE_al usarlo y se agregará al juzgar, pero no se agregará cuando se incluya .hasRole,hasAnyRoleROLE_AuthorityROLE_

permisos de método

Controle los permisos a través de anotaciones, admita anotaciones en las clases y tenga las últimas anotaciones al mismo tiempo.

5 anotaciones de permisos proporcionadas por Spring:

  • @preautorizar
  • @PostAutorizar
  • @prefiltro
  • @postfiltro
  • @Asegurado

Anotaciones del protocolo JSR-250:

  • @rolespermitidos
  • @PermitAll
  • @DenyAll

Estas anotaciones deben poder utilizar@EnableGlobalMethodSecurity

@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true, jsr250Enabled = true)

prePostEnabled = true: Abrir @PreAuthorize,@PostAuthorize,@PreFilter ,@PostFilter4 anotaciones

securedEnabled = true: @Securedanotación abierta

jsr250Enabled = true: @RolesAllowed,@PermitAll,@DenyAllanotación abierta

anotación de permiso de primavera

Las anotaciones deben estar habilitadas@EnableGlobalMethodSecurity(prePostEnabled = true)

@preautorizar

Generalmente, se utiliza esta anotación, y esta anotación es relativamente simple y truetiene derechos de acceso cuando el valor es;

    // 这种方式匹配的是ROLE_dev,内部会默认添加ROLE_
    @PreAuthorize("hasRole('dev')")
    @GetMapping("/method1")
    public String method1() {
    
    
        return "method1";
    }

    // 匹配ROLE_dev
    @PreAuthorize("hasAuthority('ROLE_dev')")
    @GetMapping("/method2")
    public String method2() {
    
    
        return "method2";
    }

    // 只能由用户名带后缀 _ad 的访问
    @PreAuthorize("principal.username.endsWith('_ad')")
    @GetMapping("/method4")
    public String method4() {
    
    
        return "method4";
    }

    // 只能由用户名和参数相等
    @PreAuthorize("principal.username.equals(#name)")
    @GetMapping("/method44")
    public String method44(String name) {
    
    
        return "method44";
    }

    // 新增的用户,用户名只能是 _ad 结尾
    @PreAuthorize("#user.username.endsWith('_ad')")
    @PostMapping("/method5")
    public String method5(@RequestBody SysUser user) {
    
    
        return "method5";
    }

 	// 是否是 admin 的角色,无关大小写
    // principal 为内置对象
    @PreAuthorize("principal?.isAdmin()")
    @GetMapping("/method7")
    public String method7() {
    
    
        return "method7";
    }

Además hay una herencia de roles.

@Bean
static RoleHierarchy roleHierarchy() {
    
    
    return new RoleHierarchyImpl("ROLE_admin > ROLE_dev");
}
    // 配置了 ROLE_admin > ROLE_dev 
    // dev 继承了admin 的权限,使用admin的用户访问
    @PreAuthorize("hasRole('dev')")
    @GetMapping("/method6")
    public String method6() {
    
    
        return "method6";
    }

@PostAutorizar

La función de esta anotación es verificar el permiso después de que se ejecuta el método, y cuando el valor truees el permiso de acceso;

    // 返回的用户id必须 =10
    // principal 为内置对象
    @PostAuthorize("returnObject.id.equals('10')")
    @GetMapping("/method8")
    public SysUser method8() {
    
    
        SysUser result = new SysUser();
        result.setId("10");
        return result;
    }

@prefiltro

@PreFilterLos parámetros del tipo de colección se pueden filtrar y falselos elementos se eliminan cuando se establece el valor.

   // 过滤出 id= 1 的元素
    // filterObject 内置对象,表示集合中的每一个元素
    // userList 集合对象
    @PreFilter(value = "filterObject.id.equals('1')", filterTarget = "userList")
    @PostMapping("/method9")
    public List<SysUser> method9(@RequestBody List<SysUser> userList) {
    
    
        return userList;
    }

imagen-20230615221149418

@postfiltro

@PostFilterLa respuesta del tipo de colección se puede filtrar para falseeliminar elementos cuando el valor es .

    // 过滤出 id != 1 的元素
    @PostFilter(value = "!filterObject.id.equals('1')")
    @PostMapping("/method10")
    public List<SysUser> method10(@RequestBody List<SysUser> userList) {
    
    
        return userList;
    }

imagen-20230615221219433

@Asegurado

Esta anotación se usa especialmente para determinar si el usuario tiene el rol, el valor es el nombre del rol, recuerda agregar el prefijoROLE_

    // 只允许角色为 ROLE_admin 的用户访问
    @Secured("ROLE_admin")
    @PostMapping("/method11")
    public List<SysUser> method11(@RequestBody List<SysUser> userList) {
    
    
        return userList;
    }

Anotaciones JSR-250

@RolesAlowe

El valor es una matriz de nombres de roles, recuerde agregarROLE_

    // 允许角色为 ROLE_admin ROLE_dev 的用户访问
    @RolesAllowed({
    
    "ROLE_admin", "ROLE_dev"})
    @PostMapping("/method12")
    public List<SysUser> method12(@RequestBody List<SysUser> userList) {
    
    
        return userList;
    }

@PermitAll

Publicación directa, sin verificación de permisos, se puede @RolesAllowedcombinar y no se puede usar con anotaciones de permisos definidas por Spring;

@RolesAllowed("ROLE_test1")
@RestController
@RequestMapping("/method2")
public class MethodController2 {
    
    

    @PermitAll
    @GetMapping("/get1")
    public String method1() {
    
    
        return "get1";
    }
}

@DenyAll

Por el contrario @PermitAll , nadie puede acceder

    @DenyAll
    @GetMapping("/get2")
    public String method2() {
    
    
        return "get2";
    }

permisos dinámicos

principio:

La implementación de permisos se puede refinar a URL, es decir, permisos de menú. Por supuesto, los permisos a nivel de botón también pertenecen a los permisos de menú, y los permisos de menú se montan en roles. Por lo tanto, estos permisos de menú se pueden obtener a través de la sesión iniciada. información del usuario Luego, el control de permisos de acceso se puede realizar en el interceptor.

opcion uno

Crear lista de consultas de roles y permisos

@Data
public class RolePermission {
    
    

    private Integer roleId;

    private String roleName;

    private String roleCode;

    private Integer permissionId;

    private Integer parentPermissionId;

    private String permissionCode;

    private String permissionName;

    private String permissionUrl;
}

    @Select("select a.id as role_id,a.name as role_name,a.code as role_code,b.name as permission_name,b.code as permission_code,b.id as permission_id , b.parent_id as parent_permission_id,b.url as permission_url "
        + "from sys_role a inner join sys_permission b on a.id = b.role_id where a.deleted = 0")
    List<RolePermission> roleList();

Después de iniciar el proyecto, almacene roles y funciones en caché para facilitar el acceso;

Si se hace esto, el caché debe actualizarse al modificar los permisos de rol;

@Service
@AllArgsConstructor
public class RoleServiceImpl implements RoleService , InitializingBean {
    
    

    private CacheManager cacheManager;
    private RoleRepository roleRepository;

    @Override
    public void afterPropertiesSet() throws Exception {
    
    
        List<RolePermission> roleList = roleRepository.roleList();
        if (roleList.isEmpty()) {
    
    
            return;
        }
        Map<String, List<RolePermission>> permissionMap = roleList.stream().collect(Collectors.groupingBy(RolePermission::getRoleCode));
        for (Map.Entry<String, List<RolePermission>> entry : permissionMap.entrySet()) {
    
    
            String key = AuthConstant.ROLE_PRE + entry.getKey();
            List<String> collect = entry.getValue().stream().map(RolePermission::getPermissionUrl)
                                        .filter(StringUtils::isNoneBlank).collect(Collectors.toList());
            String value = JSON.toJSONString(collect);
            cacheManager.set(key, value);
        }
    }
}

Cree un solo filtro PermissionFilterpara juzgar si la solicitud tiene permiso;

public class PermissionFilter extends OncePerRequestFilter {
    
    

    private final CacheManager cacheManager;

    public PermissionFilter(CacheManager cacheManager) {
    
    
        this.cacheManager = cacheManager;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest servletRequest, HttpServletResponse servletResponse, FilterChain filterChain)
    throws ServletException, IOException {
    
    
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        if (authentication == null || authentication.getPrincipal() == null) {
    
    
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        if (authentication.getPrincipal() instanceof String) {
    
    
            filterChain.doFilter(servletRequest, servletResponse);
            return;
        }
        // 获取认证信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        if (authorities.isEmpty()) {
    
    
            throw new AccessDeniedException("无权限!");
        }
        // 获取权限信息
        Set<String> permissions = new HashSet<>();
        for (GrantedAuthority authority : authorities) {
    
    
            Object o = cacheManager.get(authority.getAuthority());
            if (o != null) {
    
    
                List<String> collect = JSONArray.parseArray(o.toString()).stream().map(Object::toString).collect(Collectors.toList());
                permissions.addAll(collect);
            }
        }
        if (permissions.isEmpty()) {
    
    
            throw new AccessDeniedException("无权限!");
        }
        // 判断是否有权限访问
        String api = servletRequest.getRequestURI();
        if (!permissions.contains(api)) {
    
    
            throw new AccessDeniedException("无权限!");
        }
        filterChain.doFilter(servletRequest, servletResponse);
    }
}

Luego agregue el filtro de permiso al FilterSecurityInterceptorfrente, ExceptionTranslationFilterdetrás;

        http.addFilterAfter(new PermissionFilter(cacheManager), FilterSecurityInterceptor.class);

Opción II

control de autenticación personalizado2

En esta solución, modifique la implementación del administrador de decisiones dentro de la seguridad, trate de no mover las cosas de seguridad;
primero personalice el administrador de decisiones de acceso

/**
 * 自定义访问决策管理器
 * @author ALI
 * @since 2023/6/16
 */
public class CustomAccessDecisionManager implements AccessDecisionManager {
    
    

    private final CacheManager cacheManager;

    public CustomAccessDecisionManager(CacheManager cacheManager) {
    
    
        this.cacheManager = cacheManager;
    }
    @Override
    public void decide(Authentication authentication, Object object, Collection<ConfigAttribute> configAttributes)
    throws AccessDeniedException, InsufficientAuthenticationException {
    
    
        // 获取认证信息
        Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
        if (authorities.isEmpty()) {
    
    
            throw new AccessDeniedException("无权限!");
        }
        // 获取权限信息
        Set<String> permissions = new HashSet<>();
        for (GrantedAuthority authority : authorities) {
    
    
            Object o = cacheManager.get(authority.getAuthority());
            if (o != null) {
    
    
                List<String> collect = JSONArray.parseArray(o.toString()).stream().map(Object::toString).collect(Collectors.toList());
                permissions.addAll(collect);
            }
        }
        if (permissions.isEmpty()) {
    
    
            throw new AccessDeniedException("无权限!");
        }
        FilterInvocation filterInvocation = (FilterInvocation) object;
        // 判断是否有权限访问
        String api = filterInvocation.getRequestUrl();
        if (!permissions.contains(api)) {
    
    
            throw new AccessDeniedException("无权限!");
        }
    }

    @Override
    public boolean supports(ConfigAttribute attribute) {
    
    
        return true;
    }

    @Override
    public boolean supports(Class<?> clazz) {
    
    
        return true;
    }
}

Modificar la configuración de seguridadSecurityWebConfig

    @Override
    public void init(WebSecurity web) throws Exception {
    
    
        HttpSecurity http = getHttp();
        web.addSecurityFilterChainBuilder(http).postBuildAction(() -> {
    
    
            FilterSecurityInterceptor securityInterceptor = http.getSharedObject(FilterSecurityInterceptor.class);
            securityInterceptor.setAccessDecisionManager(new CustomAccessDecisionManager(cacheManager));
            web.securityInterceptor(securityInterceptor);
        });
    }

Hay otra forma de configurar aquí: el método de configuración recomendado oficial de Spring ( Java Configuration :: Spring Security ), que nos brinda la capacidad de modificar directamente las propiedades internas de los objetos. En comparación con el método anterior, esto puede modificar casi todos los objetos, incluidos :config,filter,handler,interceptor,provider,strategy,point,voter

http.anyRequest().authenticated()
            .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
    
    
                public <O extends FilterSecurityInterceptor> O postProcess(O fsi) {
    
    
                    fsi.setPublishAuthorizationSuccess(true);
                    fsi.setAccessDecisionManager(new CustomAccessDecisionManager(cacheManager));
                    return fsi;
                }
            });

Configuración completa:

@Configuration
@EnableWebSecurity
public class SecurityWebConfig extends WebSecurityConfigurerAdapter {
    
    
    private static final String loginApi = "/loginDeal";
    private static final String usernameParameter = "username";
    private static final String passwordParameter = "pwd";

    public static PrivateKey privateKey;

    @Autowired
    private UserDetailsService userDetailsService;
    @Autowired
    private CustomSecurityContextRepository securityContextRepository;
    @Autowired
    private CacheManager cacheManager;

    public SecurityWebConfig() throws Exception {
    
    
        try (InputStream is = this.getClass().getClassLoader().getResourceAsStream("privateKey.pem")) {
    
    
            if (is == null) {
    
    
                throw new RuntimeException("没有读取的密钥!!!");
            }
            byte[] data = new byte[2048];
            int length = is.read(data);
            String privateKeyString = new String(data, 0, length);
            privateKey = RsaUtil.getPrivateKey(privateKeyString.trim());
        }
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
    
    
        return new BCryptPasswordEncoder();
    }

    @Bean
    public CorsFilter corsFilter() {
    
    
        return new CorsFilter(corsConfigurationSource());
    }

    @Bean
    public MathGenerator mathGenerator() {
    
    
        return new MathGenerator(1);
    }

    /**
     * 自定义的customUsernamePasswordAuthenticationFilter
     * 需要同步在HttpSecurity里的配置
     */
    public CustomUsernamePasswordAuthenticationFilter customUsernamePasswordAuthenticationFilter() throws Exception {
    
    
        CustomUsernamePasswordAuthenticationFilter result = new CustomUsernamePasswordAuthenticationFilter();
        result.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher(loginApi));
        result.setAuthenticationManager(this.authenticationManager());
        result.setUsernameParameter(usernameParameter);
        result.setPasswordParameter(passwordParameter);
        result.setAuthenticationSuccessHandler(new LoginSuccessHandler());
        result.setAuthenticationFailureHandler(new LoginFailureHandler());
        return result;
    }

    /**
     * 跨域设置
     */
    private CorsConfigurationSource corsConfigurationSource() {
    
    
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        CorsConfiguration config = new CorsConfiguration();
        // 允许cookies跨域
        config.setAllowCredentials(true);
        // 允许向该服务器提交请求的URI,* 表示全部允许,在SpringMVC中,如果设成*,会自动转成当前请求头中的Origin
        config.addAllowedOrigin("*");
        // 允许访问的头信息,* 表示全部
        config.addAllowedHeader("*");
        // 预检请求的缓存时间(秒),即在这个时间段里,对于相同的跨域请求不会再预检了
        config.setMaxAge(18000L);
        // 允许提交请求的方法,* 表示全部允许
        config.addAllowedMethod("GET");
        config.addAllowedMethod("PUT");
        config.addAllowedMethod("POST");
        config.addAllowedMethod("DELETE");
        source.registerCorsConfiguration("/**", config);
        return source;
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
    
    
        auth.inMemoryAuthentication()
            .withUser("ali")
            .password(passwordEncoder().encode("123456"))
            .roles("admin");
        auth.userDetailsService(userDetailsService);
    }

    @Override
    public void init(WebSecurity web) throws Exception {
    
    
        HttpSecurity http = getHttp();
        web.addSecurityFilterChainBuilder(http).postBuildAction(() -> {
    
    
            // 因为FilterSecurityInterceptor是在其他配置完成后执行的,所以只能在这里修改
            // 详细看 org.springframework.security.config.annotation.web.builders.WebSecurity#performBuild
            // 配置 CustomAccessDecisionManager 方式一
            FilterSecurityInterceptor securityInterceptor = http.getSharedObject(FilterSecurityInterceptor.class);
            securityInterceptor.setAccessDecisionManager(new CustomAccessDecisionManager(cacheManager));
            web.securityInterceptor(securityInterceptor);
        });
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
    
    
        // 表单
        // 注意:使用了`BasicAuthenticationFilter`那么在配置`WebSecurityConfigurerAdapter`时,就不要设置`http.httpBasic()`,不然不会走我们自定义的Filter
        http
            .formLogin()
            // 登录的请求接口,对应表单的action,这里只是修改了处理的api,实际处理还是security
            .loginProcessingUrl("/loginDeal")
            // 登录的用户名和密码参数名称
            .usernameParameter("username")
            .passwordParameter("pwd")
            .successHandler(new LoginSuccessHandler())
            .failureHandler(new LoginFailureHandler())
            .permitAll()
            .and().csrf().disable(); //关闭csrf防护

        // 将我们的repository设置到共享变量里
        http.setSharedObject(SecurityContextRepository.class, securityContextRepository);

        // 设置序列化
        http.exceptionHandling().authenticationEntryPoint(new CustomizeAuthenticationEntryPoint())
            .accessDeniedHandler(new CustomAccessDeniedHandler());

        http.addFilterBefore(customUsernamePasswordAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);

        http.authorizeRequests()
            .antMatchers("/test/*", "/login/**").permitAll()
            .antMatchers("/getData2").hasRole("dev")
            .antMatchers("/getData3").hasRole("admin")
            .antMatchers("/getData4").hasAnyRole("test1")
            .antMatchers("/getData5").hasAuthority("ROLE_test1")
            .antMatchers("/getData6").hasIpAddress("127.0.0.1")
            .antMatchers("/getData7").hasAnyAuthority("ROLE_test2", "ROLE_test3")
            .anyRequest().authenticated()
            // 配置 CustomAccessDecisionManager 方式二
//            .withObjectPostProcessor(new ObjectPostProcessor<FilterSecurityInterceptor>() {
    
    
//                public <O extends FilterSecurityInterceptor> O postProcess(O fsi) {
    
    
//                    fsi.setPublishAuthorizationSuccess(true);
//                    fsi.setAccessDecisionManager(new CustomAccessDecisionManager(cacheManager));
//                    return fsi;
//                }
//            })
        ;
    }
}

Dirección del almacén

https://gitee.com/LIRUIYI/test-security.git

https://gitee.com/LIRUIYI/test-security-web.git

Supongo que te gusta

Origin blog.csdn.net/qq_28911061/article/details/131271018
Recomendado
Clasificación