【智能排班系统】基于SpringSecurity实现登录验证、权限验证

文章目录

SpringSecurity介绍

SpringSecurity是一款专为Java应用程序设计的身份验证和授权框架。它提供了声明式的安全访问控制解决方案,使开发者能够轻松实现用户认证(Authentication)、授权(Authorization)、防止常见安全攻击以及会话管理等功能。作为Spring生态系统的一部分,Spring Security无缝集成于Spring MVC、Spring Boot等项目中,极大地简化了安全相关的开发工作,确保应用程序具备坚实的安全防线

SpringSecurity可以轻松实现细致到按钮级别的权限控制,又因为排班系统有系统管理员、门店管理员、普通员工,每种角色的权限不同,因此非常适合使用SpringSecurity

sss-security实现

依赖

<!-- Spring Security依赖 -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <scope>provided</scope>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.projectlombok</groupId>
    <artifactId>lombok</artifactId>
</dependency>
<dependency>
    <groupId>com.alibaba</groupId>
    <artifactId>transmittable-thread-local</artifactId>
    <version>2.14.2</version>
</dependency>

工具类

Jwt工具

用来根据用户信息生成令牌(token),同时可以根据token解析出一些关键信息

package com.dam.utils;

import io.jsonwebtoken.*;
import org.springframework.util.StringUtils;

import java.util.Date;

/**
 * 生成JSON Web Token的工具类
 */
public class JwtUtil {

    /**
     * JWT的默认过期时间,单位为毫秒。这里设定为一年(365天)
     */
    private static long tokenExpiration = 365 * 24 * 60 * 60 * 1000;
    /**
     * 在实际应用中,应使用随机生成的字符串
     */
    private static String tokenSignKey = "dsahdashoiduasguiewu23114";

    /**
     * 从给定的JWT令牌中提取指定参数名对应的值。
     *
     * @param token     需要解析的JWT令牌字符串
     * @param paramName 要提取的参数名
     * @return 参数值(字符串形式),如果令牌为空、解析失败或参数不存在,则返回null
     */
    public static String getParam(String token, String paramName) {
        try {
            if (StringUtils.isEmpty(token)) {
                return null;
            }
            // 使用提供的密钥解析并验证JWT
            Jws<Claims> claimsJws = Jwts.parser().setSigningKey(tokenSignKey).parseClaimsJws(token);
            // 获取JWT的有效载荷(claims),其中包含了所有声明(参数)
            Claims claims = claimsJws.getBody();
            // 提取指定参数名对应的值
            Object param = claims.get(paramName);
            // 如果参数值为空,则返回null;否则将其转换为字符串并返回
            return param == null ? null : param.toString();
        } catch (Exception e) {
            // 记录解析过程中的任何异常,并返回null
            e.printStackTrace();
            return null;
        }
    }

    /**
     * 根据用户信息生成一个新的JWT令牌。
     *
     * @param userId
     * @param username
     * @return
     */
    public static String createToken(Long userId, String username, Long enterpriseId, Long storeId, int userType) {
//        System.out.println("createToken userType:" + userType);
        // 使用Jwts.builder()构建JWT
        String token = Jwts.builder()
                // 设置JWT的主题(subject),此处为常量"AUTH-USER"
                .setSubject("AUTH-USER")
                // 设置过期时间,当前时间加上预设的过期时间(tokenExpiration)
                .setExpiration(new Date(System.currentTimeMillis() + tokenExpiration))
                // 有效载荷
                .claim("userId", userId)
                .claim("username", username)
                .claim("enterpriseId", enterpriseId)
                .claim("storeId", storeId)
                .claim("userType", userType)
                // 使用HS512算法和指定密钥对JWT进行加密
                .signWith(SignatureAlgorithm.HS512, tokenSignKey)
                // 使用GZIP压缩算法压缩JWT字符串,将字符串变成一行来显示
                .compressWith(CompressionCodecs.GZIP)
                // 完成构建并生成紧凑格式的JWT字符串
                .compact();
        return token;
    }


    public static String getUserId(String token) {
        return getParam(token, "userId");
    }

    public static String getUsername(String token) {
        return getParam(token, "username");
    }

    public static String getEnterpriseId(String token) {
        return getParam(token, "enterpriseId");
    }

    public static String getStoreId(String token) {
        return getParam(token, "storeId");
    }

    public static String getUserType(String token) {
        return getParam(token, "userType");
    }

}

JSON响应工具

ResponseUtil 的作用是为 Spring MVC 应用程序提供一种便捷的方式来构建和发送 JSON 格式的 HTTP 响应

package com.dam.utils;

import com.alibaba.fastjson.JSON;
import com.dam.model.result.R;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;

import javax.servlet.http.HttpServletResponse;
import java.io.IOException;

public class ResponseUtil {

    public static void out(HttpServletResponse response, R r) {
        ObjectMapper mapper = new ObjectMapper();
        response.setStatus(HttpStatus.OK.value());
        response.setContentType(MediaType.APPLICATION_JSON_UTF8_VALUE);
        try {
            System.out.println("ResponseUtil r:"+ JSON.toJSONString(r));
            mapper.writeValue(response.getWriter(), r);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

加密工具类

本文使用盐值加密来对用户密码进行加密,盐值加密是一种增强密码安全性的技术,主要用于防止密码被轻易破解,特别是在密码数据库遭到泄露的情况下。其核心思想是在密码哈希过程中引入一个额外的、随机生成的值——称为“盐值”,以此来增加密码的唯一性和复杂度,添加盐值有如下作用:

  • 防止彩虹表攻击:彩虹表是一种预先计算好的哈希值与明文密码的映射表,用于快速破解已知哈希算法(如MD5、SHA-1等)生成的密码。通过添加盐值,即使两个用户使用相同的密码,由于盐值不同,其哈希结果也会大相径庭,从而大大削弱彩虹表的有效性。

  • 抵御字典攻击和暴力破解:盐值使得每个用户密码的哈希值都独一无二,即使是最常用的密码,加上随机盐值后,也需要针对特定盐值进行单独破解,显著增加了攻击者的计算成本。

盐值加密的实现直接使用SpringSecurity自带的工具即可

package com.dam.utils;

import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;

/**
 * 加密工具
 */
public class EncryptionUtil {
    private static BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();

    /**
     * 盐值MD5加密
     *
     * @param strSrc
     * @return
     */
    public static String saltMd5Encrypt(String strSrc) {
        return passwordEncoder.encode(strSrc);
    }

    /**
     * 判断原密码和加密之后的密码是否相符
     *
     * @param originalPassword
     * @param encryptPassword
     * @return
     */
    public static boolean isSaltMd5Match(String originalPassword, String encryptPassword) {
        return passwordEncoder.matches(originalPassword, encryptPassword);
    }

    public static void main(String[] args) {
        System.out.println(EncryptionUtil.saltMd5Encrypt("123456"));
    }
}

用户上下文

用户上下文主要用来记录用户的关键信息,以便同线程共享,无需每次从token中解析,提高效率。使用阿里巴巴的TransmittableThreadLocal库替代标准的java.lang.ThreadLocal,目的是确保在使用线程池或Fork/Join框架等场景下,线程间可以正确地传递(或“传播”)ThreadLocal变量的值。这对于处理跨越多个线程的任务(如异步操作、任务调度等)时保持用户上下文的连续性非常重要。

用户信息实体类

package com.dam.context;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * @Author dam
 * @create 2024/4/2 16:11
 */
@Data
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class UserInfoDTO {
    private String userId;
    private String userName;
}

用户上下文

package com.dam.context;

import com.alibaba.ttl.TransmittableThreadLocal;

import java.util.Optional;

/**
 * @Author dam
 * @create 2024/4/2 16:12
 */
public class UserContext {
    /**
     * 定义一个私有的、静态的ThreadLocal变量,类型为UserInfoDTO,用于存储当前线程关联的用户信息
     */
    private static final ThreadLocal<UserInfoDTO> USER_THREAD_LOCAL = new TransmittableThreadLocal<>();

    /**
     * 设置用户至上下文
     *
     * @param user 用户详情信息
     */
    public static void setUser(UserInfoDTO user) {
        USER_THREAD_LOCAL.set(user);
    }

    /**
     * 获取上下文中用户 ID
     *
     * @return 用户 ID
     */
    public static String getUserId() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        // 使用Optional进行空值安全处理:如果userInfoDTO不为空,则提取其userId属性并返回;否则返回null
        return Optional.ofNullable(userInfoDTO)
                .map(UserInfoDTO::getUserId)
                .orElse(null);
    }

    /**
     * 获取上下文中用户名称
     *
     * @return 用户名称
     */
    public static String getUsername() {
        UserInfoDTO userInfoDTO = USER_THREAD_LOCAL.get();
        return Optional.ofNullable(userInfoDTO)
                .map(UserInfoDTO::getUserName)
                .orElse(null);
    }

    /**
     * 清理用户上下文
     */
    public static void removeUser() {
        // 从ThreadLocal变量中移除当前线程关联的用户信息,释放资源
        USER_THREAD_LOCAL.remove();
    }
}

自定义重写

自定义无权限的报错

package com.dam.custom;

import com.dam.model.result.R;
import com.dam.utils.ResponseUtil;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

/**
 * 自定义没有权限的报错信息,默认是报403
 */
@Component//交给spring管理
public class CustomAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response,
                       AccessDeniedException accessDeniedException) {
        // 获取请求的URI
        String uri = request.getRequestURI();
        // 获取请求的方法
        String method = request.getMethod();
        // 获取当前用户的用户名
//        String username = request.getRemoteUser();
        // 获取用户的IP地址
//        String ip = request.getRemoteAddr();
        // 获取用户的浏览器类型
//        String userAgent = request.getHeader("User-Agent");
        // 构造错误信息
        String errorMsg = "没有权限访问当前资源:" + uri + " (" + method + ")";
        ResponseUtil.out(response, R.error(403, errorMsg));
    }
    
}

自定义密码加密

package com.dam.custom;

import com.dam.utils.EncryptionUtil;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;

/**
 * 自定义密码组件
 */
@Component//交给spring管理
public class CustomMd5PasswordEncoder implements PasswordEncoder {
    /**
     * 指定密码的加密方式
     *
     * @param rawPassword
     * @return
     */
    @Override
    public String encode(CharSequence rawPassword) {
        return EncryptionUtil.saltMd5Encrypt(rawPassword.toString());
    }

    /**
     * 判断用户所输入的密码和加密之后的密码是否相同
     *
     * @param rawPassword
     * @param encodedPassword
     * @return
     */
    @Override
    public boolean matches(CharSequence rawPassword, String encodedPassword) {
        boolean equals = EncryptionUtil.isSaltMd5Match(rawPassword.toString(), encodedPassword);
        if (equals == true) {
            return true;
        } else {
            System.out.println("登录密码验证不通过,密码错误");
//            System.out.println("原密码,rawPassword:" + rawPassword);
//            System.out.println("原密码加密:" + encrypt);
//            System.out.println("数据库中已加密的密码,encodedPassword:" + encodedPassword);
            return false;
        }
    }
}

自定义用户类

继承security的User,增加一些自己的信息方便后续使用,security的User主要用来存储用户名、密码、权限信息

package com.dam.custom;

import com.dam.model.entity.system.UserEntity;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;

import java.util.Collection;

/**
 * 自定义用户对象
 */
public class CustomUser extends User {

    /**
     * 我们自己的用户实体对象,要调取用户信息时直接获取这个实体对象
     */
    private UserEntity sysUser;

    public CustomUser(UserEntity sysUser, Collection<? extends GrantedAuthority> authorities) {
        super(sysUser.getUsername(), sysUser.getPassword(), authorities);
        this.sysUser = sysUser;
    }

    public UserEntity getSysUser() {
        return sysUser;
    }

    public void setSysUser(UserEntity sysUser) {
        this.sysUser = sysUser;
    }

}



过滤器

登录过滤器

package com.dam.filter;


import com.alibaba.fastjson.JSON;
import com.dam.constant.RedisConstant;
import com.dam.custom.CustomUser;
import com.dam.model.entity.system.UserEntity;
import com.dam.model.enums.ResultCodeEnum;
import com.dam.model.result.R;
import com.dam.model.vo.system.LoginVo;
import com.dam.service.RecordLoginLogService;
import com.dam.utils.JwtUtil;
import com.dam.utils.ResponseUtil;
import com.dam.utils.ip.IpUtil;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.*;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * 登录过滤器,继承UsernamePasswordAuthenticationFilter,对用户名密码进行登录校验
 */
public class TokenLoginFilter extends UsernamePasswordAuthenticationFilter {

    private StringRedisTemplate redisTemplate;
    /**
     * 登录日志服务,用于记录用户的登录情况
     * 方便对系统的用户活跃情况进行统计
     */
    private RecordLoginLogService loginLogService;

    /**
     * 构造方法
     *
     * @param authenticationManager 认证管理器,负责实际的用户身份验证
     */
    public TokenLoginFilter(AuthenticationManager authenticationManager, StringRedisTemplate redisTemplate, RecordLoginLogService sysLoginLogService) {
//        System.out.println("登录验证过滤");
        this.setAuthenticationManager(authenticationManager);
        this.redisTemplate = redisTemplate;
        this.loginLogService = sysLoginLogService;
        // 不只是可以post
        this.setPostOnly(false);
        // 指定登录接口及提交方式,可以指定任意路径(我们默认的登陆路径)
        this.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/system/login/login", "POST"));
    }

    /**
     * 登录认证,覆盖父类实现
     *
     * @param req HTTP请求对象
     * @param res HTTP响应对象
     * @return 认证后的Authentication对象
     * @throws AuthenticationException 认证过程中抛出的异常
     */
    @Override
    public Authentication attemptAuthentication(HttpServletRequest req, HttpServletResponse res)
            throws AuthenticationException {
        System.out.println("进行登录认证-----------------------------------------------------------------------------------------");
        try {
            // 使用Jackson ObjectMapper从请求流中反序列化登录信息对象
            LoginVo loginVo = new ObjectMapper().readValue(req.getInputStream(), LoginVo.class);
            System.out.println("loginVo:" + JSON.toJSONString(loginVo));

            // 判断登录验证码是否正确
            String redisKey = RedisConstant.Verification_Code + loginVo.getUuid();
            String verificationCode = redisTemplate.opsForValue().get(redisKey);
            if (verificationCode == null) {
                throw new AuthenticationServiceException("验证码已经失效,请刷新之后再重新登录");
            }
            if (!verificationCode.toLowerCase().equals(loginVo.getVerificationCode().toLowerCase())) {
                throw new AuthenticationServiceException("验证码输入不正确");
            }
            // 创建UsernamePasswordAuthenticationToken,封装登录信息
            Authentication authenticationToken = new UsernamePasswordAuthenticationToken(loginVo.getUsername(), loginVo.getPassword());
//            System.out.println("authenticationToken:" + authenticationToken.toString());
            // 调用父类的authenticate方法,通过认证管理器进行实际的身份验证,会判定登陆密码和数据库密码是否一致
            Authentication authenticate = this.getAuthenticationManager().authenticate(authenticationToken);
            System.out.println("登录验证成功");
            return authenticate;
        } catch (IOException e) {
            System.out.println("登录验证失败");
            throw new RuntimeException(e);
        }

    }

    /**
     * 登录成功后的处理方法,覆盖父类实现
     *
     * @param request
     * @param response
     * @param chain
     * @param auth     当前验证对象
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void successfulAuthentication(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
                                            Authentication auth) {
        System.out.println("登录成功,生成token------------------------------------------------------------------------------------------");
        // 获取当前用户信息
        CustomUser customUser = (CustomUser) auth.getPrincipal();
        // 保存权限数据到redis
        String redisKey = RedisConstant.AUTHORITY_PERMISSION + customUser.getUsername();
        System.out.println("保存用户权限到redis中,redisKey:" + redisKey);
        //设置缓存过期时间是十五天
        redisTemplate.opsForValue().set(redisKey,
                JSON.toJSONString(customUser.getAuthorities()),
                15,
                TimeUnit.DAYS);

        // 生成token
        UserEntity sysUser = customUser.getSysUser();
        String token = JwtUtil.createToken(sysUser.getId(), sysUser.getUsername(), sysUser.getEnterpriseId(), sysUser.getStoreId(), sysUser.getType());
        System.out.println("token:" + token);

        // 记录登录日志
        loginLogService.recordLoginLog(customUser.getUsername(), 0, IpUtil.getIpAddress(request), "登录成功", sysUser.getEnterpriseId(), sysUser.getStoreId());

        // 将token返回给前端
        Map<String, Object> map = new HashMap<>();
        map.put("token", token);
        ResponseUtil.out(response, R.ok().addData("data", map));
    }

    /**
     * 登录失败后的处理方法,覆盖父类实现
     *
     * @param request  HTTP请求对象
     * @param response HTTP响应对象
     * @param e        认证失败异常
     */
    @Override
    protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response,
                                              AuthenticationException e) {
        System.out.println("登录失败------------------------------------------------------------------------------------------");
        System.out.println("失败原因:" + e.getMessage());
        // 分析具体失败原因并提供对应的错误信息
        String errorMessage;
        if (e instanceof BadCredentialsException) {
            errorMessage = "用户名或密码错误";
        } else if (e instanceof DisabledException) {
            errorMessage = "账户已被禁用,请联系管理员";
        } else if (e instanceof LockedException) {
            errorMessage = "账户已被锁定,请联系管理员";
        } else if (e instanceof AuthenticationServiceException) {
            errorMessage = "认证服务异常,请稍后重试";
        } else {
            errorMessage = "登录失败";
        }
        ResponseUtil.out(response, R.error(ResultCodeEnum.DATA_ERROR.getCode(), errorMessage));
    }
}

权限过滤器

这段代码定义了一个名为TokenAuthenticationFilter的类,它继承自Spring Security的OncePerRequestFilter,用于处理每个HTTP请求,解析并验证请求头中的Token,以及将认证信息放入Spring Security的上下文中。

package com.dam.filter;

import com.alibaba.fastjson.JSON;
import com.dam.constant.RedisConstant;
import com.dam.context.UserContext;
import com.dam.context.UserInfoDTO;
import com.dam.model.enums.ResultCodeEnum;
import com.dam.model.result.R;
import com.dam.utils.JwtUtil;
import com.dam.utils.ResponseUtil;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
 * 认证解析token过滤器
 * OncePerRequestFilter:每次请求都要过滤
 */
public class TokenAuthenticationFilter extends OncePerRequestFilter {

    private StringRedisTemplate redisTemplate;

    public TokenAuthenticationFilter(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    /**
     * 重写OncePerRequestFilter的doFilterInternal方法,处理每个请求
     * @param request
     * @param response
     * @param chain
     * @throws IOException
     * @throws ServletException
     */
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        System.out.println("权限验证过滤");

//        logger.info("uri:" + request.getRequestURI());
//        System.out.println("request.getRequestURI():"+request.getRequestURI());
        //如果是登录接口,直接放行
        if ("/system/login/login".equals(request.getRequestURI())) {
            chain.doFilter(request, response);
            return;
        }

        // 调用getAuthentication方法尝试从请求中获取有效的Token并解析出认证信息
        UsernamePasswordAuthenticationToken authentication = getAuthentication(request);
        if (null != authentication) {
            // --if--如果获取到有效的认证信息
//            System.out.println("request:" + request.toString());
//            System.out.println("response:" + response.toString());
//            System.out.println("authentication:" + authentication.toString());
            // 将认证信息放入Spring Security的SecurityContextHolder中,以便后续请求链中使用
            SecurityContextHolder.getContext().setAuthentication(authentication);
            // 继续执行过滤链中的其他过滤器和目标处理器
            chain.doFilter(request, response);
        } else {
            // 如果未能获取到有效的认证信息,返回失败响应
            ResponseUtil.out(response, R.ok().addData("data", ResultCodeEnum.PERMISSION));
        }
    }

    /**
     * 看看是否有token,根据token是否可以获取到用户,获取不到再进行账号密码登录
     *
     * @param request
     * @return
     */
    private UsernamePasswordAuthenticationToken getAuthentication(HttpServletRequest request) {
        System.out.println("获取权限信息------------------------------------------------------------------------------------------");

        // token置于header里
        String token = request.getHeader("token");
//        logger.info("token:" + token);
        if (!StringUtils.isEmpty(token)) {
            String username = JwtUtil.getUsername(token);
//            logger.info("username:" + username);
            if (!StringUtils.isEmpty(username)) {
                // 获取授权信息
                String redisKey = RedisConstant.AUTHORITY_PERMISSION + username;
                // 权限字符串
                String authoritiesString = redisTemplate.opsForValue().get(redisKey);
                if (authoritiesString == null) {
                    return null;
                }
                // 解析权限字符串为具体的权限集合
                List<Map> mapList = JSON.parseArray(authoritiesString, Map.class);
                List<SimpleGrantedAuthority> authorities = new ArrayList<>();
                for (Map map : mapList) {
                    authorities.add(new SimpleGrantedAuthority((String) map.get("authority")));
                }
                // 存储用户上下文信息
                String userId = JwtUtil.getUserId(token);
                UserContext.setUser(UserInfoDTO.builder().userId(userId).userName(username).build());

                // 构建并返回UsernamePasswordAuthenticationToken对象,包含用户名、空密码(此处无需密码,因为已通过Token验证)和权限列表
                return new UsernamePasswordAuthenticationToken(username, null, authorities);
            }
        }
        // 如果未能成功解析Token或获取权限信息,返回null
        return null;
    }
}

Service

登录Service

package com.dam.service;

import com.dam.model.entity.system.LoginLogEntity;

public interface RecordLoginLogService {

    /**
     * 记录登录信息
     *
     * @param username 用户名
     * @param status   状态
     * @param ipaddr   ip
     * @param message  消息内容
     * @return
     */
    void recordLoginLog(String username, Integer status, String ipaddr, String message,Long enterpriseId,Long storeId);

    LoginLogEntity getById(Long id);

}

配置类

里面有一些接口当时偷懒没有在数据库里面配置相应的权限,为了开发的时候方便测试,将其放在了忽略接口路径,后续需要修改。

package com.dam.config;

import com.dam.configuration.IpFlowControlConfiguration;
import com.dam.custom.CustomAccessDeniedHandler;
import com.dam.custom.CustomMd5PasswordEncoder;
import com.dam.filter.IpFlowLimitFilter;
import com.dam.filter.TokenAuthenticationFilter;
import com.dam.filter.TokenLoginFilter;
import com.dam.service.RecordLoginLogService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableWebSecurity //开启SpringSecurity的默认行为
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级别的安全注解(如@PreAuthorize, @PostAuthorize)
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
    @Autowired
//    @Qualifier("systemUserDetailsServiceImpl") // 指定实现类
    private UserDetailsService userDetailsService;
    @Autowired
    private CustomMd5PasswordEncoder customMd5PasswordEncoder;
    @Autowired
    private StringRedisTemplate redisTemplate;
    @Autowired
//    @Qualifier("systemRecordLoginLogServiceImpl") // 指定实现类
    private RecordLoginLogService loginLogService;
    @Autowired
    private CustomAccessDeniedHandler customAccessDeniedHandler;
    @Autowired
    private IpFlowControlConfiguration ipFlowControlConfiguration;

    /**
     * 创建并返回一个AuthenticationManager实例,此方法由父类WebSecurityConfigurerAdapter提供
     *
     * @return
     * @throws Exception
     */
    @Bean
    @Override
    protected AuthenticationManager authenticationManager() throws Exception {
        return super.authenticationManager();
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        // 这是配置的关键,决定哪些接口开启防护,哪些接口绕过防护
        http
                // 自定义没有权限时的报错信息
                .exceptionHandling()
                .accessDeniedHandler(customAccessDeniedHandler)

                // 关闭CSRF(跨站请求伪造)防护
                .and().csrf().disable()

                // 开启跨域以便前端调用接口(网关已经做了全局跨域)
                //.cors().and()

                // 设置访问控制规则
                .authorizeRequests()
                // 指定某些接口不需要通过验证即可访问。登陆接口肯定是不需要认证的(在下面统一配置了)
                //.antMatchers("/system/login/login").permitAll()
                // 这里意思是其它所有接口需要认证才能访问
                .anyRequest().authenticated()

                // 添加自定义过滤器,按照顺序依次执行
                .and()
                // TokenAuthenticationFilter放到UsernamePasswordAuthenticationFilter的前面,这样做就是为了除了登录的时候去查询数据库外,其他时候都用token进行认证。
                .addFilterBefore(new TokenAuthenticationFilter(redisTemplate), UsernamePasswordAuthenticationFilter.class)
                // IpFlowLimitFilter:在TokenAuthenticationFilter之前,进行IP流量限制
                .addFilterBefore(new IpFlowLimitFilter(redisTemplate, ipFlowControlConfiguration), TokenAuthenticationFilter.class)
                // TokenLoginFilter:处理登录请求,使用AuthenticationManager进行认证,并记录登录日志
                .addFilter(new TokenLoginFilter(authenticationManager(), redisTemplate, loginLogService));

        // 禁用session (采用无状态会话管理,适用于基于Token的身份验证)
        http.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth) throws Exception {
        // 指定 UserDetailService 和 加密器
        auth.userDetailsService(userDetailsService).passwordEncoder(customMd5PasswordEncoder);
    }

    /**
     * 配置哪些请求不拦截
     * 重写configure(WebSecurity)方法
     * 使用web.ignoring().antMatchers(...)指定一系列接口路径,
     * 这些路径的请求将不会经过Spring Security的过滤链,即不受安全约束。
     *
     * @param web
     * @throws Exception
     */
    @Override
    public void configure(WebSecurity web) throws Exception {
        web.ignoring().antMatchers(
                "/favicon.ico",
                "/swagger-resources/**",
                "/webjars/**", "/v2/**",
                "/swagger-ui.html/**",
                "/doc.html",

                "/system/login/sendMailCode",
                "/system/login/usernameCheck/**",
                "/system/login/regist",
                "/system/login/enterpriseRegister",
                "/system/user/getByOpenid",
                "/system/user/getUserEntityByToken",
                "/system/user/bindWechat",
                "/system/login/generateVerificationCode",

                "/api/ucenter/wx/callback",

                "/scheduling/imserver/**",
                //定时任务需要访问
                "/scheduling/schedulingdate/judgeOneDateIsRest",
                "/scheduling/shiftuser/listStaffWorkDtoByWorkDate",
                "/system/user/getUserIdAndMailMapByUserIdList",
                "/system/user/listUserEntityByStoreId",

                "/system/menu/storeAuthoritiesToRedis",

                "/thirdParty/oss/policy",
                "/thirdParty/mail/send",
                "/api/ucenter/wx/**",
                "/thirdParty/mail/send"
        );
    }
}

说明

登录验证

使用了SpringSecurity之后,不需要再自己实现登录方法,因为在上面已经完成了验证码校验、密码校验

权限验证

系统的权限控制方式是:将菜单权限绑定到角色中,然后再将角色分配给用户。在登录成功之后,将用户对应的权限标识查询出来并存储到Redis中,当用户访问需要权限的接口时,SpringSecurity会从Redis中获取用户有的权限标识,然后判断用户是否有接口对应权限,没有则报没有权限错误

在这里插入图片描述
那么怎么给接口做权限控制呢,实现非常简单,只需要在接口上面添加注解和相应的权限标识,如@PreAuthorize("hasAuthority('bnt.sysMenu.list')")

/**
 * 列表
 */
@RequestMapping("/list")
@PreAuthorize("hasAuthority('bnt.sysMenu.list')")
public R list(@RequestParam Map<String, Object> params) {
    PageUtils page = menuService.queryPage(params);
    return R.ok().addData("page", page);
}

IP流量限制

如果看过我的代码的同学,可以还有一些IP流量限制的代码我没有讲解,感兴趣的同学可以查看【智能排班系统】基于Redis的increment命令和lua脚本实现IP限流

sss-system模块实现

Service实现

登录日志实现类

package com.dam.service.impl.security;

import com.dam.dao.LoginLogDao;
import com.dam.model.entity.system.LoginLogEntity;
import com.dam.service.RecordLoginLogService;
import com.dam.utils.ServletUtils;
import eu.bitwalker.useragentutils.UserAgent;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

//@Service("systemRecordLoginLogServiceImpl")
@Service
public class RecordLoginLogServiceImpl implements RecordLoginLogService {

    @Autowired
    private LoginLogDao loginLogDao;

    @Override
    public void recordLoginLog(String username, Integer status, String ipaddr, String message, Long enterpriseId, Long storeId) {
        LoginLogEntity sysLoginLog = new LoginLogEntity();
        sysLoginLog.setUsername(username);
        sysLoginLog.setIpaddr(ipaddr);
        sysLoginLog.setMsg(message);
        // 日志状态
        sysLoginLog.setStatus(status);

        /// 获取用户的浏览器和操作系统
        final UserAgent userAgent = UserAgent.parseUserAgentString(ServletUtils.getRequest().getHeader("User-Agent"));
        // 获取客户端操作系统
        String os = userAgent.getOperatingSystem().getName();
        sysLoginLog.setOs(os);
        // 获取客户端浏览器
        String browser = userAgent.getBrowser().getName();
        sysLoginLog.setBrowser(browser);

        /// 存储用户的企业 门店信息
        if (enterpriseId != null) {
            sysLoginLog.setEnterpriseId(enterpriseId);
        }
        if (storeId != null) {
            sysLoginLog.setStoreId(storeId);
        }

        loginLogDao.insert(sysLoginLog);
    }

//    @Override
//    public IPage<LoginLogEntity> selectPage(Page<LoginLogEntity> pageParam, LoginLogQueryVo sysLoginLogQueryVo) {
//        //获取条件值
//        String username = sysLoginLogQueryVo.getUsername();
//        String createTimeBegin = sysLoginLogQueryVo.getCreateTimeBegin();
//        String createTimeEnd = sysLoginLogQueryVo.getCreateTimeEnd();
//        //封装条件
//        QueryWrapper<LoginLogEntity> wrapper = new QueryWrapper<>();
//        if (!StringUtils.isEmpty(username)) {
//            wrapper.like("username", username);
//        }
//        if (!StringUtils.isEmpty(createTimeBegin)) {
//            wrapper.ge("create_time", createTimeBegin);
//        }
//        if (!StringUtils.isEmpty(createTimeBegin)) {
//            wrapper.le("create_time", createTimeEnd);
//        }
//        //调用mapper方法
//        IPage<LoginLogEntity> pageModel = loginLogDao.selectPage(pageParam, wrapper);
//        return pageModel;
//    }

    @Override
    public LoginLogEntity getById(Long id) {
        return loginLogDao.selectById(id);
    }

//    @Override
//    public PageUtils queryPage(Map<String, Object> params) {
//        IPage<LoginLogEntity> page = this.page(
//                new Query<LoginLogEntity>().getPage(params),
//                new QueryWrapper<LoginLogEntity>().orderByDesc("create_time")
//        );
//
//        return new PageUtils(page);
//    }
}

这段代码定义了一个名为UserDetailsServiceImpl的类,实现了Spring Security的UserDetailsService接口,用于根据用户名加载用户详细信息,包括用户权限

package com.dam.service.impl.security;


import com.dam.custom.CustomUser;
import com.dam.model.entity.system.UserEntity;
import com.dam.model.enums.system.UserCodeEnum;
import com.dam.service.MenuService;
import com.dam.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Component;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService sysUserService;
    @Autowired
    private MenuService menuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("根据用户名查询用户授权信息----------------------------------------------------------------------------");
        // 通过UserService根据用户名查询用户信息
        UserEntity sysUser = sysUserService.getUserInfoByUsername(username);
        // 用户信息不存在时抛出异常
        if (null == sysUser) {
            throw new UsernameNotFoundException("用户名不存在,请检查输入是否错误");
        }
        // 判断用户状态,如被禁用则抛出异常
        if (sysUser.getStatus().intValue() == UserCodeEnum.STATUS_BAN.getCode().intValue()) {
            throw new RuntimeException("账号已被禁用,请咨询管理员");
        }
        // 根据userId查询操作权限
        List<String> userPermsList = menuService.getUserButtonList(sysUser.getId());
        System.out.println("用户可操作按钮,userPermsList:" + userPermsList);
        // 转化成security要求的格式数据
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String perm : userPermsList) {
            authorities.add(new SimpleGrantedAuthority(perm.trim()));
        }
        return new CustomUser(sysUser, authorities);
    }

}

UserDetailsService实现类

package com.dam.service.impl.security;


import com.dam.custom.CustomUser;
import com.dam.model.entity.system.UserEntity;
import com.dam.model.enums.system.UserCodeEnum;
import com.dam.service.MenuService;
import com.dam.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    @Autowired
    private UserService sysUserService;
    @Autowired
    private MenuService menuService;

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        System.out.println("根据用户名查询用户授权信息----------------------------------------------------------------------------");
        // 通过UserService根据用户名查询用户信息
        UserEntity sysUser = sysUserService.getUserInfoByUsername(username);
        // 用户信息不存在时抛出异常
        if (null == sysUser) {
            throw new UsernameNotFoundException("用户名不存在,请检查输入是否错误");
        }
        // 判断用户状态,如被禁用则抛出异常
        if (sysUser.getStatus().intValue() == UserCodeEnum.STATUS_BAN.getCode().intValue()) {
            throw new RuntimeException("账号已被禁用,请咨询管理员");
        }
        // 根据userId查询操作权限
        List<String> userPermsList = menuService.getUserButtonList(sysUser.getId());
        System.out.println("用户可操作按钮,userPermsList:" + userPermsList);
        // 转化成security要求的格式数据
        List<SimpleGrantedAuthority> authorities = new ArrayList<>();
        for (String perm : userPermsList) {
            authorities.add(new SimpleGrantedAuthority(perm.trim()));
        }
        return new CustomUser(sysUser, authorities);
    }

}

测试

读者们可能看到上面的代码,整个人是一脸懵逼,这么多类,这么多方法,究竟是怎么执行的,下面我通过测试给出了调用链路,大家可以跟着顺序来理解上面的代码

登录失败测试

模拟密码错误,代码的执行顺序如下:

>>>>> 调用 TokenAuthenticationFilter 的 doFilterInternal
>>>>> 调用 TokenLoginFilter 的 attemptAuthentication
>>>>> 调用 UserDetailsServiceImpl 的 loadUserByUsername
>>>>> 调用 CustomMd5PasswordEncoder 的 matches
>>>>> 调用 TokenLoginFilter 的 unsuccessfulAuthentication

登录成功测试

输入正确密码,代码的执行顺序如下:

>>>>> 调用 TokenAuthenticationFilter 的 doFilterInternal
>>>>> 调用 TokenLoginFilter 的 attemptAuthentication
>>>>> 调用 UserDetailsServiceImpl 的 loadUserByUsername
>>>>> 调用 CustomMd5PasswordEncoder 的 matches
>>>>> 调用 TokenLoginFilter 的 successfulAuthentication
>>>>> 调用 RecordLoginLogServiceImpl 的 recordLoginLog

其他请求测试

发起一个除了登录之外的请求,代码的执行顺序如下:

>>>>> 调用 TokenAuthenticationFilter 的 doFilterInternal
>>>>> 调用 TokenAuthenticationFilter 的 getAuthentication

其他建议

如果对用户权限标识存储有什么不理解的地方,可以参考【智能排班系统】数据库设计的菜单表角色表用户表角色菜单中间表用户角色中间表

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/522730.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

【LeetCode】热题100:排序链表

题目&#xff1a; 给你链表的头结点 head &#xff0c;请将其按 升序 排列并返回 排序后的链表 。 示例 1&#xff1a; 输入&#xff1a;head [4,2,1,3] 输出&#xff1a;[1,2,3,4] 示例 2&#xff1a; 输入&#xff1a;head [-1,5,3,4,0] 输出&#xff1a;[-1,0,3,4,5] …

对抗样本攻击

对抗样本是指经过特殊设计或调整的输入数据&#xff0c;旨在欺骗人工智能模型&#xff0c;使其产生错误的预测或输出。对抗样本通常是通过对原始输入进行微小但精心计算的改变&#xff0c;使得模型产生意外的结果。这种模糊化的输入可能难以从人类角度甄别&#xff0c;但对机器…

python 02字符串

字符串可能是用到最多的数据类型了&#xff0c;所有标准序列操作&#xff08;索引、切片、乘法、成员资格检查、长度、最小值和最大值&#xff09;都适用于字符串 但别忘了字符串是不可变的&#xff0c;因此所有的元素赋值和切片赋值都是非法的。 1.居中效果 默认为空格 可…

纯小白蓝桥杯备赛笔记--DAY9(动态规划)

文章目录 一、动态规划基础&#xff08;1&#xff09;线性DP简介步骤例题数字三角形--1536破损的楼梯-3367安全序列-3423 &#xff08;2&#xff09;二维DP简介例题摆花--389选数异或--3711数字三角形--505 &#xff08;3&#xff09;最长公共子序列LCSLCS算法模型最长公共子序…

Docker容器与虚拟化技术:OpenEuler 部署 ES 与 Kibana

目录 一、实验 1.环境 2.OpenEuler 部署 ES (EalasticSearch) 3.OpenEuler 部署 Kibana 4.部署 Elasticvue插件 5.使用cpolar内网穿透 6.使用Elasticvue 一、实验 1.环境 &#xff08;1&#xff09;主机 表1 主机 系统架构版本IP备注LinuxopenEuler22.03 LTS SP2 1…

C语言之指针的指向地址和指针的内容总结(八十九)

简介&#xff1a; CSDN博客专家&#xff0c;专注Android/Linux系统&#xff0c;分享多mic语音方案、音视频、编解码等技术&#xff0c;与大家一起成长&#xff01; 优质专栏&#xff1a;Audio工程师进阶系列【原创干货持续更新中……】&#x1f680; 优质专栏&#xff1a;多媒…

Notion 开源替代品 AFFINE 部署和使用教程

AFFiNE 是一款完全开源的 Notion Miro 替代品&#xff0c;与 Notion 相比&#xff0c;AFFiNE 更注重隐私安全&#xff0c;优先将笔记内容保存到本地。 GitHub 地址&#xff1a;https://github.com/toeverything/AFFiNE AFFiNE 使用 Rust 和 Typescript 构建&#xff0c;只需…

【微服务】SpringCloud之Feign远程调用

&#x1f3e1;浩泽学编程&#xff1a;个人主页 &#x1f525; 推荐专栏&#xff1a;《深入浅出SpringBoot》《java对AI的调用开发》 《RabbitMQ》《Spring》《SpringMVC》《项目实战》 &#x1f6f8;学无止境&#xff0c;不骄不躁&#xff0c;知行合一 文章目录 …

【漏洞复现】易宝OA GetProductInv接口存在SQL注入漏洞

漏洞描述 易宝OA是一款功能非常强大的办公软件。用户通过软件里可以轻松的进行移动办公,功能非常的丰富,可以满足用户的各种办公需求,让你的办公效率有了有效的提高,通过软件里可以轻松的解决用户的各种办公事项。易宝OA GetProductInv接口存在SQL注入漏洞。 免责声明 …

【LeetCode】--- 动态规划 集训(二)

目录 一、63. 不同路径 II1.1 题目解析1.2 状态转移方程1.3 解题代码 二、931. 下降路径最小和2.1 题目解析2.2 状态转移方程2.3 解题代码三、174. 地下城游戏3.1 题目解析3.2 状态转移方程3.3 解题代码 一、63. 不同路径 II 题目地址&#xff1a; 不同路径 II 一个机器人位于…

机器学习数据预处理—统计分析方法

数据预处理 1 数据规范化 量纲&#xff0c;指将一个物理导出量用若干基本量的乘方之积表示出来的表达式。数据的比较需要关注两点——绝对数值和量纲&#xff0c;而特征间因为量纲的存在导致无法直接通过绝对数值比较大小&#xff0c;也就无法判断特征间的重要性。例如若某个…

中霖教育:注册会计师专业阶段考试难吗?

注册会计师专业阶段考试包含的考试科目众多&#xff0c;涉及的专业领域广泛&#xff0c;所以整体的难度相对较高。根据历年统计数据&#xff0c;该阶段的通过率平均约为22%&#xff0c;综合阶段的通过率在72%。 针对专业阶段&#xff0c;具体包括以下六门科目&#xff1a;《会…

DC9 Debian和sql注入

信息收集 sudo arp-scan -l 列出局域网主机 arp-scan向局域网中所有可能的ip地址发出arp请求包&#xff0c;如果得到arp回应&#xff0c;就证明局域网中某台主机使用了该ip dc9的ip &#xff1a; 192.168.146.133 访问网页 cms为Debian 端口扫描 22端口是filtered 隐藏目…

C++ 内存分配时地址对齐

如果数据地址的对齐与CPU相兼容&#xff0c;那么CPU读写内存时性能会更高。 因此在C中&#xff0c;有时会希望在堆或栈中分配内存时&#xff0c;返回的地址能按照特定的长度对齐。 如果希望在栈中分配的内存时&#xff0c;返回地址按照特定长度对齐&#xff0c;可以使用 alig…

Vue+OpenLayers7入门到实战:OpenLayers如何销毁已经创建好的地图容器

返回《Vue+OpenLayers7》专栏目录:Vue+OpenLayers7入门到实战 前言 本章介绍如何使用OpenLayers7在地图上如何销毁已经创建好的地图容器。 在某些场景下,可能会需要销毁之前的地图,重新创建新的地图的需要,因此本章介绍一下在开始创建地图前如何先销毁之前的地图的功能。…

sharo反序列化漏洞

启动docker 服务 sudo service docker start 打开靶场 sudo docker run -d -p 80:8080 medicean/vulapps:s_shiro_1 输入docker虚拟机地址打开靶机sharo框架 如何利用漏洞 打开工具目录在终端中打开 输入靶机地址 打开yaki监听端口可以设置为6666 返回工具填写靶机ip和端口 …

【二分查找】Leetcode 山脉数组的峰顶索引

题目解析 852. 山脉数组的峰顶索引 这到题使用暴力枚举的查找方法发现这段数组是有二段性的&#xff0c;峰顶左边的一段区间是一段递增区间&#xff0c;右边的一段区间是一段递减区间 算法讲解 class Solution { public:int peakIndexInMountainArray(vector<int>&am…

微信小程序备案指南及注意事项

如何备案小程序&#xff1f; 原文可参考&#xff1a; 微信小程序备案指南及注意事项 注意&#xff1a;备案需要提前准备好以下材料&#xff1b; 身份证正反面照片&#xff08;必须&#xff09;&#xff1b;营业执照照片&#xff08;非个人主体需要&#xff09;&#xff1b; 一…

C#学习笔记8:接口、委托、事件

今日继续我的C#学习之路&#xff0c;今日学习接口、委托、事件&#xff0c;文章从实践出发学习这三个设计理念&#xff0c;并提供完整源码 目录 1、接口(多重继承)&#xff1a; 代码&#xff1a; 运行结果&#xff1a; 2、委托&#xff08;方法的代理/函数指针&#xff09;&…

一款轻量、干净的 Vue 后台管理框架

开始之前 在开始介绍之前我想谈谈为什么要自己做一个后台管理&#xff0c;我知道很多人都用一些开源的后台管理项目&#xff0c;这些老前辈有很多亮点值得学习&#xff0c;但是存在的一些问题同样不可忽视&#xff0c;我认为很多开发者会被困扰(仅代表个人观点) 技术栈老旧不升…