Spring Security Practical Articles

foreword

As the first article, this article will use examples to illustrate the usage of Spring Security in production and expand its functions. Each solution will have a complete example code, and the code warehouse will be posted at the end of the article.

The theory involved in this article is less, mainly with examples.

Memory version (memory)

This version has no technology, and it can be used by introducing dependencies.

direct import

<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. After introducing dependencies, you can start the project directly
  2. A random password will be generated in the console, and the default username isuser

Using generated security password: 2233be62-e65f-489b-a52c-4bba21bcfd14

  1. set user password

    1. It can be set through application.yml, or injected through config, obviously it is more convenient to use yml

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

      image-20230529212637658

    2. There are two ways to write directly through the code, and the two can also be mixed

      Method 1: direct injectionInMemoryUserDetailsManager

      @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");
          }
      }
      

      Method 2: Interface-oriented method

         
      @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"));
          }
      }
      

Database version (jdbc)

This version, on the basis of the memory version, has upgraded the function of user verification, extending from the built-in user to the database, and it is also a relatively limited version.

Then the focus here is the implementation userDetailServicemethod, UserDetailjust return a subclass, and other login authentication and redirection are all done by security.

     <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;
    }
}

User-defined security query method

@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()));
    }
}

When injecting here, users of the memory version are retained.

@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);
    }
}

I use Mybatis plus, add mapperScan here

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


application.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

Custom Login - Single (custom-login-single)

This version upgrades the login custom function on the database version, and you can set your own login page, home page, login processing api, and callback processing for successful and failed logins according to the project;

Digression: The advantage of not separating the front and back is rapid development and easy deployment, but its disadvantages are also obvious. With the iterative project, it will become larger and larger. The mainstream is still distributed, and single projects are rare now.

So here are the things to note:

  1. After the front-end framework is introduced, it needs to be configured in application.yml (I use themleaf here)
  2. Inheritance WebSecurityConfigurerAdapterclass completes custom configuration
    1. Use settings for successful login page defaultSuccessUrl, don't usesuccessForwardUrl
    2. You can define a successful post-processor and a failure processor, these two will be called back after authentication, and you can do some business expansion
  3. The configuration loginProcessingUrlis not to transfer the processing request to the interface you defined, but to modify the api address inside the security

Notice:

loginProcessingUrl: The api for login processing, this is just to define the api, not to change the authentication to custom, or to be controlled by security

defaultSuccessUrl: The jump address after successful login, there is another one successForwardUrl, but it can only be used defaultSuccessUrl, and successForwardUrla 405 error will appear when using it

permitAll: allow all request methods

        <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防护
    }

Login failure callback

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

Successful login callback

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

test controller

@Controller
public class LoginController {
    
    

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

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

Tested login page (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>

test home page

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

Custom login - front and rear separated

Unlike a single project, the scalability of front-to-back separation is greatly increased, which means that it will face more problems, such as cross-domain and session consistency

  1. Customize the method CustomUserServiceImplof loading UserDetailsServiceuser information

  2. Custom LoginSuccessHandler/LoginFailureHandlerauthentication callback processing method (successful login, store user information, response serialization)

  3. Create a cache interface CacheManagerunified cache

  4. inheritWebSecurityConfigurerAdapter

    Configuration:

    corsFilter(跨域)

    userDetailService(用户信息)

    passwordEncoder(加解密)

    securityContextRepository(认证信息管理)

    AuthenticationEntryPoint(响应序列号)

  5. There are many ways to deal with session consistency. Different people have different opinions. There are advantages and disadvantages. Redis is used here. If there is no need for a large environment, one front-end and one back-end are also possible.

Cache management, redis used here;

@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;
    }

}

response object

/**
 * 返回结果对象
 *
 * @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();
    }
}

Note that:

Our previous codes are all services based on a single architecture, so when configuring SecurityWebConfig, we will configure login pages, error pages, etc., but these things are no longer needed after the front and back are separated.

image-20230608230929174

Session Consistency Scheme

When it comes to the session consistency scheme, here are three schemes, which are gradually developed.

Option One

Use redis cache directly in the callback method (custom-login)

This solution uses the callback processor of successful login in security to set user information to redis, then add a request header interceptor to intercept the token in the request header, get the user information in redis through token, and then set it to the context of security SecurityContextHolderThat's fine; the disadvantage of this solution is that it will keep two caches, and the amount of code is also large

  1. Customize the method CustomUserServiceImplof loading UserDetailsServiceuser information
  2. Custom LoginSuccessHandler/LoginFailureHandlerauthentication callback processing method (successful login, store user information, response serialization)
  3. Create a cache interface CacheManagerunified cache
  4. Custom CustomizeAuthenticationEntryPointimplementation of AuthenticationEntryPointthe serialization method
  5. Custom CustomHeaderAuthFilterinheritance BasicAuthenticationFilterto complete the processing of the request header token
  6. LoginSuccessHandlerAdd user caching logic
  7. Inherit WebSecurityConfigurerAdapterconfiguration custom login

Notice:

  1. If you use it , don’t set it when BasicAuthenticationFilterconfiguring , or you won’t go through our custom FilterWebSecurityConfigurerAdapterhttp.httpBasic()

  2. This method will be separated from security. In customization CustomHeaderAuthFilter, you need to judge the login API and ignore API to avoid being intercepted by yourself

LoginSuccessHandler: After the user logs in successfully, set the user information to the cache and serialize json to the 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: A custom request header filter that intercepts the token header in the request header. This is equivalent to our own initiative to authenticate. Then this filter must be before the filter. After authentication, you need to pass the UsernamePasswordAuthenticationFilterauthentication The information is set in the security context, and because our filter is a bit independent from the security, we need to synchronize the SecurityWebConfigignored api configured in it;

It should be noted that all of them try catchare captured here, so that any exceptions ExceptionTranslationFilterwill be handled by

/**
 * 主要用来拦截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: Here is the configuration of security. It should be noted that don't forget to inject the custom class and configure it

/**
 * 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);
    }
}

image-20230605223036913

image-20230605223058424

Option II

Rewrite the cache provider class inside Spring Security SecurityContextRepository(custom-login2)

This solution starts from the perspective of the source code framework, and the replacement execution beanhas achieved that the final access to user information is the same cache space. It is very simple in principle, but it requires some source code knowledge, and the business requirements are changeable. Although it supports securityexpansion , but the learning cost is not low, so you need to look at the project from a global perspective, but it is very comfortable to use, and the amount of code is very small, if you want to check the internal use, you can check it SecurityContextPersistenceFilter.

  1. Customize the method CustomUserServiceImplof loading UserDetailsServiceuser information
  2. Custom LoginSuccessHandler/LoginFailureHandlerauthentication callback processing method (response serialization)
  3. Create a cache interface CacheManagerunified cache
  4. Custom CustomizeAuthenticationEntryPointimplementation of AuthenticationEntryPointthe serialization method
  5. Custom CustomSecurityContextRepositoryinheritance SecurityContextRepositoryoverrides its 3 information access methods
  6. Inherit WebSecurityConfigurerAdapterconfiguration custom login
/**
 * 自定义的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();
    }

}

security configuration

@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());
    }
}

Why use this: http.setSharedObject?

Look at SecurityContextConfigurerthe configuration class, it will http.getSharedObject(SecurityContextRepository.class)obtain the corresponding implementation class, which is internally implemented by Map, so when we http.setSharedObjectset our custom repository, we can rewrite it; by default, , http.getSharedObject(SecurityContextRepository.class) = nullso, it is directly new HttpSessionSecurityContextRepositoryset toSecurityContextPersistenceFilter

image-20230618104650675

third solution

use Spring session(custom-login3)

This solution uses spring components spring session, which is also a native spring, and it is also aimed at session management; again, different people have different opinions.

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

and then annotate@SpringBootApplication

Then it's done. This method saves the most effort. After all, this is a component produced by spring. It is a functional upgrade for the spring framework. This method does not require you to set tokens and other things.

image-20230606214039967

Why not use JWT

In fact, there is another way to solve session consistency, but I don't really support this way, that is, JWTit is defined as a stateless login, and its core meaning is that the JWTgenerated information is the login information, so as long as With this information, the login data can JWTbe obtained through this information.

It's really good, but there are a few things I don't really like:

  1. There is a part of the information generated by JWT that can be reversed to extract user information;
  2. JWT cannot actively expire, so this cannot be launched in the true sense, then to achieve expiration, it must be cached, then this is the same as the second solution, but the second solution does not need to write decryption, verify the legitimacy of the JWT, and does not need to go Adapt security;
  3. This is similar to the above, that is, if the business requires login restrictions, such as only one login with the same account, then JWT still needs to be cached;

The above 3 points are enough for me not to support using JWT.

Change login to json login (custom-login-json)

Security’s default login is all POST + form-data. If you use json, you can’t get the parameters, but the front-end projects generally do unified interception processing. Of course, you can also let the front-end change the login to formData request.

There are several points to note about this login:

  1. The default login authentication UsernamePasswordAuthenticationFilteris done by , its value is not in json format, so we rewrite its authentication method attemptAuthentication;

  2. Set our custom UsernamePasswordAuthenticationFiltersettings to HttpSecurity, because it is a replacement, so we need to UsernamePasswordAuthenticationFiltercopy the relevant configuration, such as the login parameter name, login processing api, login success processor and failure processor ( this is very important );

  3. PasswordEncoderPassword matching is to pass in unencrypted (passed by the front end) and encrypted (saved by the back end) passwords, so if the password is encrypted, it needs to be decrypted here;

Customize UsernamePasswordAuthenticationFilterthe authentication of 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);
        }
    }
}

Add custom configurationHttpSecurity

    @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;
    }

Then addFilterBeforethis method is provided by security for us to expand. If we customize one UsernamePasswordAuthenticationFilterand put it in front, after our authentication is passed, we will not continue to go through the following filters, and our coverage is completed.

image-20230608232743758

certified

Here, the custom-login-json project is used as the basic project.

Because if you want to attack your website, the first possibility is to try to crack the password. If your transmission is not secure, it will be intercepted, so you need to encrypt it. Moreover, if the attacker uses brute force to attack, the password requires It needs to be complicated, increase the range of exhaustion of the attacker, reduce the hit rate, and increase the number of failures of the account, as well as the ip limit. At the same time, you can also set the verification code to increase the difficulty of cracking.

Password encrypted login (custom-auth)

Network security is also something that our programmers should consider, so here we do a password encryption and decryption, here we use the second scheme (custom-login2) as the basic project, but the following scheme is not limited to a certain scheme, but It is applicable to all security items.

encryption tool

The RSA asymmetric encryption algorithm is used here, and both encryption and decryption require the use of public and secret keys, which is highly secure.

/**
 * 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));
    }
}

generate key pair

Execute RsaUtilthe main method, generate publicKeyand privateKey, and then privateKeysave it in the backend and publicKeygive it to the frontend, and then when the frontend passes the password to the backend, it first passes RSAand publicKeyencrypts;

image-20230609212038780

Put the private key in the file: privateKey.pem

, use pemor dersuffix format file storage, otherwise there will be problems in parsing

image-20230609223545679

read private key

SecurityWebConfigAdd a constructor to read the private key during initialization (this method can be used for packaging and can be used with confidence);

privateKey is set to public and can be called anywhere in the project.

    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());
        }
    }

filter decryption

CustomUsernamePasswordAuthenticationFilterDecrypt the password in the custom one, and then set it authenticationTokenin, because the latter verification method matchesneeds to match the password before encryption and the password after encryption;

@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);
        }
    }
}

Of course, the decryption process can also be decrypted at the place that really matches, and the rewriting org.springframework.security.authentication.dao.DaoAuthenticationProvider#additionalAuthenticationChecksmethod has the same logic, so there is no need to write code.

image-20230618121936190

Add custom filter to security

    @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);
    }

Front-end configuration and login

Here use the template project (), after git down, install the encryption tool

npm install jsencrypt

npm install

  1. Modify axios.jsthe end Axios.interceptors.response.to the following:

    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. Add login method

    export function login (data, success, error) {
          
          
      http.post('/loginDeal', data, success, error)
    }
    
    
  3. Configure the key into an environment variable

    Here only configuration is required dev.env.jsin and prod.env.jsin, dev is the development environment, and prod is the production environment.

    Note: The variable value here is marked with a double quote and a single quote, so that when used, it will not be webpackdirectly replaced at compile time and cause a syntax error.

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

    image-20230610160724135

  4. Create login.vue(I simply configured it here)

    For the login form, I separate the password field entered by the user from the password field transmitted to the backend, in order to improve the user experience.

    <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.jsAdd routing

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

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

    image-20230609213618235

result

image-20230610175318500

Captcha login (custom-auth-captcha)

Continue to complete the above example of encryption and decryption.

The principle is that the backend randomly generates a series of numbers, saves them, and then generates a picture for the frontend, or randomizes a simple calculation formula, such as: , then calculates the value, 1+8=saves it, and then generates a picture for the frontend with this formula. The focus is on the before and after How to connect, I use it here sessionId.

Note that the verification code cannot be used all the time, and the randomness of the verification code needs to be provided through time and verification strategies.

generate verification code

The tools used by the captcha tool here Hutool,

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

Here I inject the tool generated by the algorithm as a singleton, and then use it during verification, and then the verification code expires in 2 minutes

/**
 * 认证
 *
 * @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("验证码异常!");
        }
    }
}

I didn’t use the hutool tool directly here captcha.write(os);, because if I do this, it will write the byte stream directly to the front end, and then the front end needs to process it again, so here I get the byte, encrypt it again, and Base64then Written for the front-end, so that the front-end only needs to spell one before the result data:image/png;base64,to use it.

Provide verification code interface

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

image-20230610151104259

Create captcha filter

Customize a filter and add it to the security interception chain.

The custom verification code filter here, inherited OncePerRequestFilter, will only go once, of course, it only needs to go once, and delete the key after the verification is successful;

/**
 * 自定义的验证码过滤器
 *
 * @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);
    }
}

Add filter to security

@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);
    }
}

I added the verification code filter here WebAsyncManagerIntegrationFilterto the front. It is the first filter, and the verification code verification party is the first one CustomizeAuthenticationEntryPoint.

Front-end display verification code (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>

Put the verification code on the url here, so that the backend can get the verification code directly

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

result

image-20230610174815817

Interface verification code

In this case, you need to verify the verification code when logging in.

rear end

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

The same here, delete the verification code after the verification is successful;

   @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;
    }

Frontend (login3.vue)

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

To log in here, verify the verification code first, and then call the login after success. There are thousands of methods, and I am giving an example here.

   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()
      })
    }

Authorization/Access Control (custom-auth-control)

Response serialization for permissions

Similarly CustomizeAuthenticationEntryPoint, configuration serialization is required

/**
 * 访问拒绝处理器
 * @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();
    }
}

SecurityWebConfigChange setting

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

Configuration of permissions

When saving the role information in our table to security, we need to do some processing. The following is the constructor for customizing user information. Here, a prefix is ​​added to the code of the user role. This is the default method of the security framework 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());
        }
    }

Enable annotation interception

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

}

path authority

WebSecurityConfigurerAdapterThis is accomplished through inheritance 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();
    }

In security, the role will be prefixed by default , so the prefix should be omitted ROLE_when using it , and it will be added when judging, but it will not be added when it is included .hasRole,hasAnyRoleROLE_AuthorityROLE_

method permissions

Control permissions through annotations, support annotations on classes, and have the latest annotations at the same time.

5 permission annotations provided by spring:

  • @PreAuthorize
  • @PostAuthorize
  • @PreFilter
  • @PostFilter
  • @Secured

JSR-250 protocol annotations:

  • @RolesAllowed
  • @PermitAll
  • @DenyAll

These annotations need to be able to use@EnableGlobalMethodSecurity

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

prePostEnabled = true: Open @PreAuthorize,@PostAuthorize,@PreFilter ,@PostFilter4 annotations

securedEnabled = true: open @Securedannotation

jsr250Enabled = true: open @RolesAllowed,@PermitAll,@DenyAllannotation

spring permission annotation

Annotations need to be enabled@EnableGlobalMethodSecurity(prePostEnabled = true)

@PreAuthorize

Generally, this annotation is used, and this annotation is relatively simple, and truehas access rights when the value is;

    // 这种方式匹配的是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";
    }

In addition there is a role inheritance

@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";
    }

@PostAuthorize

The function of this annotation is to check the permission after the method is executed, and when the value trueis the access permission;

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

@PreFilter

@PreFilterThe parameters of the collection type can be filtered, and falseelements are removed when the value is set.

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

image-20230615221149418

@PostFilter

@PostFilterThe response of the collection type can be filtered to falseremove elements when the value is .

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

image-20230615221219433

@Secured

This annotation is specially used to determine whether the user has the role; the value is the role name, remember to add the prefixROLE_

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

JSR-250 annotations

@RolesAllowe

The value is an array of role names, remember to addROLE_

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

@PermitAll

Direct release, no permission verification, can be @RolesAllowedmixed with, and cannot be used with spring-defined permission annotations;

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

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

@DenyAll

Conversely @PermitAll , no one can access

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

dynamic permissions

principle:

The implementation of permissions can be refined to url, that is, menu permissions. Of course, button-level permissions also belong to menu permissions, and menu permissions are mounted on roles. Therefore, these menu permissions can be obtained through the logged-in user information. Then access Permission control can be realized in the interceptor.

Option One

Create role and permission query list

@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();

After starting the project, cache roles and roles for easy access;

If this is done, the cache needs to be updated when modifying role permissions;

@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);
        }
    }
}

Create a single filter PermissionFilterto judge whether the request has permission;

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);
    }
}

Then add the permission filter to FilterSecurityInterceptorthe front, ExceptionTranslationFilterbehind;

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

Option II

custom-auth-control2

In this solution, modify the implementation of the decision manager inside the security, try not to move the security stuff;
first customize the access decision manager

/**
 * 自定义访问决策管理器
 * @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;
    }
}

Modify the security configurationSecurityWebConfig

    @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);
        });
    }

There is another way to set up here: Spring’s official recommended configuration method ( Java Configuration :: Spring Security ), which provides us with the ability to directly modify internal object properties. Compared with the above method, this can modify almost all objects, including :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;
                }
            });

Complete configuration:

@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;
//                }
//            })
        ;
    }
}

Warehouse Address

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

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

Guess you like

Origin blog.csdn.net/qq_28911061/article/details/131271018