一、前置知识

Spring Security 的配置类解析在下面这篇博客里面。

若依框架 SpringSecurity :基础配置源码全拆解(附图解)-CSDN博客

二、controller层

admin包下的SysLoginController

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

/**
 * 用户登录对象
 * 
 * @author ruoyi
 */
public class LoginBody
{
    /**
     * 用户名
     */
    private String username;

    /**
     * 用户密码
     */
    private String password;

    /**
     * 验证码
     */
    private String code;

    /**
     * 唯一标识
     */
    private String uuid;

    //省略getter,setter
}

2.1 AjaxResult

/**
 * 操作消息提醒
 * 
 * @author ruoyi
 */
public class AjaxResult extends HashMap<String, Object>
  • 继承自 HashMap,天然具备键值对存储能力。可以非常灵活地扩展返回字段,除了内置的 codemsgdata,还能随时添加其他业务需要的字段。就像login这个方法里面就往里面put了token

  • 它定义了三个静态常量作为标准响应字段,统一了前后端交互的协议:
  • CODE_TAG = "code":状态码,用于标识请求的处理结果(如成功、失败、权限不足等)。
  • MSG_TAG = "msg":提示信息,用于返回给用户的文本说明。
  • DATA_TAG = "data":数据对象,用于返回接口的业务数据。

三、登录总体流程

进入SysLoginService

    /**
     * 登录验证
     * 
     * @param username 用户名
     * @param password 密码
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public String login(String username, String password, String code, String uuid)
    {
        // 验证码校验
        validateCaptcha(username, code, uuid);
        // 登录前置校验
        loginPreCheck(username, password);
        // 用户验证
        Authentication authentication = null;
        try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
        // 生成token
        return tokenService.createToken(loginUser);
    }
  • 前置校验:先验证验证码格式和账号状态。
  • 身份验证:用 Spring Security 进行用户名和密码的验证。
  • 异常处理:验证失败时异步记录失败日志,并抛出对应异常。
  • 成功收尾:验证成功后异步记录成功日志,更新用户登录信息,并生成返回 JWT 令牌。

四、验证码校验

    /**
     * 校验验证码
     * 
     * @param username 用户名
     * @param code 验证码
     * @param uuid 唯一标识
     * @return 结果
     */
    public void validateCaptcha(String username, String code, String uuid)
    {
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        if (captchaEnabled)
        {
            String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + StringUtils.nvl(uuid, "");
            String captcha = redisCache.getCacheObject(verifyKey);
            if (captcha == null)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
                throw new CaptchaExpireException();
            }
            redisCache.deleteObject(verifyKey);
            if (!code.equalsIgnoreCase(captcha))
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
                throw new CaptchaException();
            }
        }
    }
  • 先判断:检查系统是否开启验证码,未开启则直接跳过校验。
  • 取缓存:通过 uuid 从 Redis 获取已生成的验证码。
  • 验有效性:缓存中无验证码(过期 / 不存在)则抛异常。
  • 删缓存:无论后续校验结果如何,先删除验证码确保一次性使用。
  • 比内容:忽略大小写对比用户输入与缓存验证码,不匹配则抛异常。

4.1读取系统配置

打开SysConfigServiceImpl

    /**
     * 获取验证码开关
     * 
     * @return true开启,false关闭
     */
    @Override
    public boolean selectCaptchaEnabled()
    {
        String captchaEnabled = selectConfigByKey("sys.account.captchaEnabled");
        if (StringUtils.isEmpty(captchaEnabled))
        {
            return true;
        }
        return Convert.toBool(captchaEnabled);
    }

    /**
     * 根据键名查询参数配置信息
     * 
     * @param configKey 参数key
     * @return 参数键值
     */
    @Override
    public String selectConfigByKey(String configKey)
    {
        String configValue = Convert.toStr(redisCache.getCacheObject(getCacheKey(configKey)));
        if (StringUtils.isNotEmpty(configValue))
        {
            return configValue;
        }
        SysConfig config = new SysConfig();
        config.setConfigKey(configKey);
        SysConfig retConfig = configMapper.selectConfig(config);
        if (StringUtils.isNotNull(retConfig))
        {
            redisCache.setCacheObject(getCacheKey(configKey), retConfig.getConfigValue());
            return retConfig.getConfigValue();
        }
        return StringUtils.EMPTY;
    }
  • 先查 Redis 缓存:有值直接返回,提升效率。
  • 缓存无值查数据库:从sys_config表查询对应配置。
  • 查成存入缓存:数据库查到结果后,写入 Redis 供后续使用。
  • 兜底返回空串:无查询结果时返回空字符串,避免空指针异常。

若依数据库中的配置表

思考:为什么采用配置表而不是读取配置文件?

  • 配置文件(如 application.yml)是静态的,修改后必须重启服务才能生效。数据库存储的配置可以在后台管理界面直接修改,即时生效,无需重启,适合线上业务不中断的场景。
  • 在微服务或集群部署中,多台服务器的配置文件需要手动同步,容易出错。数据库作为唯一的配置源,所有服务实例都从同一处读取,天然保证了配置的一致性。
  • 配置文件适合存储简单的键值对,但对于复杂的业务配置(如多租户、多环境的差异化配置),难以结构化管理。数据库可以通过表结构、索引等方式,高效存储和查询复杂的配置数据。

4.2 加载系统配置

那我们顺便来看一下这些配置是怎么加载到数据库中的。

定位SysConfigServiceImpl

    /**
     * 项目启动时,初始化参数到缓存
     */
    @PostConstruct
    public void init()
    {
        loadingConfigCache();
    }

    /**
     * 加载参数缓存数据
     */
    @Override
    public void loadingConfigCache()
    {
        List<SysConfig> configsList = configMapper.selectConfigList(new SysConfig());
        for (SysConfig config : configsList)
        {
            redisCache.setCacheObject(getCacheKey(config.getConfigKey()), config.getConfigValue());
        }
    }
  • 项目启动阶段:Spring 容器初始化这个类,完成依赖注入。
  • 自动触发初始化@PostConstruct 注解触发 init() 方法。
  • 加载配置数据init() 调用 loadingConfigCache(),从数据库查询所有系统配置,这里查询条件直接new了一个,所有属性都是null,那么条件查询回来的都是全部的配置。
  • 写入缓存:遍历配置列表,将每个配置项以键值对的形式存入 Redis。

4.3 生成验证码

校验验证码的逻辑中,是直接从redis中读取的,那我们来追踪一下这个验证码是什么时候生成的。定位到CaptchaController

/**
 * 验证码操作处理
 * 
 * @author ruoyi
 */
@RestController
public class CaptchaController
{
    @Resource(name = "captchaProducer")
    private Producer captchaProducer;

    @Resource(name = "captchaProducerMath")
    private Producer captchaProducerMath;

    @Autowired
    private RedisCache redisCache;
    
    @Autowired
    private ISysConfigService configService;
    /**
     * 生成验证码
     */
    @GetMapping("/captchaImage")
    public AjaxResult getCode(HttpServletResponse response) throws IOException
    {
        AjaxResult ajax = AjaxResult.success();
        boolean captchaEnabled = configService.selectCaptchaEnabled();
        ajax.put("captchaEnabled", captchaEnabled);
        if (!captchaEnabled)
        {
            return ajax;
        }

        // 保存验证码信息
        String uuid = IdUtils.simpleUUID();
        String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + uuid;

        String capStr = null, code = null;
        BufferedImage image = null;

        // 生成验证码
        String captchaType = RuoYiConfig.getCaptchaType();
        if ("math".equals(captchaType))
        {
            String capText = captchaProducerMath.createText();
            capStr = capText.substring(0, capText.lastIndexOf("@"));
            code = capText.substring(capText.lastIndexOf("@") + 1);
            image = captchaProducerMath.createImage(capStr);
        }
        else if ("char".equals(captchaType))
        {
            capStr = code = captchaProducer.createText();
            image = captchaProducer.createImage(capStr);
        }

        redisCache.setCacheObject(verifyKey, code, Constants.CAPTCHA_EXPIRATION, TimeUnit.MINUTES);
        // 转换流信息写出
        FastByteArrayOutputStream os = new FastByteArrayOutputStream();
        try
        {
            ImageIO.write(image, "jpg", os);
        }
        catch (IOException e)
        {
            return AjaxResult.error(e.getMessage());
        }

        ajax.put("uuid", uuid);
        ajax.put("img", Base64.encode(os.toByteArray()));
        return ajax;
    }
}
  • 先判断系统是否开启验证码,未开启直接返回结果。(这里的逻辑和我们校验验证码的逻辑一样)
  • 唯一标识:生成 uuid,构造 Redis 缓存键。
  • 生成验证码:从配置属性类中读取验证码的类型。
  • 返回结果:将答案存入 Redis 并设置过期时间,图片转 Base64 后,与 uuid 一起返回给前端。

思考:

  • 为什么要生成验证码时检验一次配置,校验验证码的时候检验一次配置,不麻烦吗?
    • 因为要考虑实际环境的动态性,假如生成验证码之后校验验证码之前关闭了验证码功能,那这个还需要校验吗?而且配置信息在redis中存放,获取是比较快的。
  • 验证码生成的uuid和我们用户的唯一标识uuid是同一个吗?
    • 不是。用户的uuid是在创建令牌时生成的,并且我们验证码的uuid在校验成功后就删除了,不会保留这么长时间,所以就算我们把验证码关了,也不会影响到我们用户的uuid。
    • 那我们登录时的LoginBody中的uuid就是我们验证码生成的uuid,用来校验的,而我们用户的uuid是在登录后的。
  • 为什么是否生成验证码是由数据库来管理,而验证码的类型却放在配置属性类里面来读取?
    • 是否生成验证码

      • 变更频率高:这是一个运营级开关,管理员需要在后台随时启用或禁用,比如在系统维护、活动高峰时快速调整。
      • 影响范围小:只控制验证码的 “有无”,不改变验证码的生成逻辑,不需要重启服务,实时生效即可。
      • 用户体验优先:通过数据库存储,可以做到 “秒级开关”,避免因为修改配置文件而重启服务,影响用户访问。
    • 验证码类型(math/char)

      • 变更频率极低:这是技术选型,比如决定用算术验证码还是字符验证码,一旦确定就很少变动。
      • 影响范围大:它会直接改变验证码的生成逻辑,更换类型通常需要验证兼容性,所以适合在发布时统一调整。
      • 性能与稳定性优先:放在配置文件里,项目启动时就加载到内存,避免了每次生成验证码都去查数据库的性能损耗。
    • 如果所有配置都放数据库,高频访问的基础配置每次都查库,增加数据库 IO 压力,拖慢系统响应。 所有配置都放配置属性类,灵活性就不会那么强。

五、登录前置校验

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

这里针对用户信息进行了2次校验,防止有些绕开前端直接发送请求(比如我们开发时通过Swagger、Apifox发送请求)

5.1 后端二次校验场景

  • 用户认证 / 授权相关:登录、注册、修改密码、刷新令牌、绑定手机号。
  • 数据增删改操作:新增用户、修改订单、删除数据、提交表单(尤其是涉及金额、权限、个人信息的)。
  • 涉及资源 / 资金相关:支付、提现、兑换、文件上传(校验文件类型、大小、内容)。
  • 高频访问 / 易被攻击的接口:验证码获取、登录接口、短信发送接口(防止刷取)。

5.2 可简化 / 无需后端二次校验(非核心场景)

  • 纯查询类请求(无敏感数据、无参数风险)
  • 查询公开信息(如网站公告、商品列表(非敏感字段筛选)、帮助文档)。
  • 无参数的简单查询(如查询当前系统时间、查询自己的公开个人信息(已通过登录授权))。
  • 参数已被上游统一校验
  • 比如请求参数已经被网关、拦截器做了统一格式校验(如 ID 必须是数字、分页参数必须合法),后端业务层可简化重复校验。

六、用户认证总体流程

        // 用户验证
        Authentication authentication = null;
        try
        {
            UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(username, password);
            AuthenticationContextHolder.setContext(authenticationToken);
            // 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
            authentication = authenticationManager.authenticate(authenticationToken);
        }
        catch (Exception e)
        {
            if (e instanceof BadCredentialsException)
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.password.not.match")));
                throw new UserPasswordNotMatchException();
            }
            else
            {
                AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, e.getMessage()));
                throw new ServiceException(e.getMessage());
            }
        }
        finally
        {
            AuthenticationContextHolder.clearContext();
        }
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_SUCCESS, MessageUtils.message("user.login.success")));
        LoginUser loginUser = (LoginUser) authentication.getPrincipal();
        recordLoginInfo(loginUser.getUserId());
  • 声明Authentication对象并置空,构建UsernamePasswordAuthenticationToken(用户名 + 密码的认证令牌)。将令牌存入AuthenticationContextHolder,绑定当前线程的认证上下文。
  • 调用authenticationManager.authenticate(...)触发认证:底层会调用UserDetailsServiceImpl.loadUserByUsername查用户,对比密码完成认证,返回认证后的Authentication对象。
  •  异常处理(分两类)异步记录登录失败日志
  • 执行AuthenticationContextHolder.clearContext(),清空当前线程的认证上下文,避免内存泄漏。
  • 认证成功后:异步记录登录成功日志→ 从认证对象获取LoginUser用户信息 → 调用recordLoginInfo记录用户登录信息。

6.1 AuthenticationContextHolder和SecurityContextHolder

我们在配置篇已经结合源码解读了SecurityContextHolder其实本质上就是thread local,放入安全上下文后后续补就可以直接获取。

而AuthenticationContextHolder是我们若依自己写的,底层也是thread local。

/**
 * 身份验证信息
 * 
 * @author ruoyi
 */
public class AuthenticationContextHolder
{
    private static final ThreadLocal<Authentication> contextHolder = new ThreadLocal<>();

    public static Authentication getContext()
    {
        return contextHolder.get();
    }

    public static void setContext(Authentication context)
    {
        contextHolder.set(context);
    }

    public static void clearContext()
    {
        contextHolder.remove();
    }
}
  • SecurityContextHolder:是 Spring Security 原生核心类,是整个安全框架的基础,负责管理全局的 SecurityContext 安全上下文。职责更全面,它管理的是整个安全上下文,除了 Authentication,还可以存放其他安全相关状态,是 Spring Security 实现线程绑定安全信息的核心。
  • AuthenticationContextHolder:是 若依框架自定义工具类,仅在若依生态中使用,是一个简化版的身份信息持有器。职责非常单一,仅聚焦于 Authentication 对象的存取,是若依为了简化业务代码而做的轻量封装。
  • Spring Security 会自动在请求开始时初始化上下文,请求结束时通过过滤器自动清理 ThreadLocal,避免内存泄漏和用户信息串用。
  • AuthenticationContextHolder:需要若依框架自己处理 ThreadLocal 的设置与清理,通常会在登录成功时调用 setContext(),在请求结束时手动调用 remove() 来清理。

6.2 AuthenticationContextHolder的存放

思考:

  • 为什么不能把用户名和参数作为参数来传递?而是要存入AuthenticationContextHolder,用完再销毁?
    • 核心思想是将认证信息(Authentication)与线程绑定,让后续都能统一地从 AuthenticationContextHolder中获取当前用户认证,而不是依赖方法参数传递。

    • 后续的每一段代码都需要你手动传递 username 或 Authentication 对象,这会让业务逻辑和认证逻辑高度耦合。

    • AuthenticationContextHolder使用 ThreadLocal 存储上下文,这意味着每个请求线程的安全上下文是隔离的,不会出现多用户信息混乱的问题。如果直接用方法参数传递,在异步调用、多线程场景下很容易出现线程安全问题,而框架已经帮你处理了这些细节。

  • 为什么一定要用完再销毁,不销毁会怎么样?
    • AuthenticationContextHolder 默认是用 ThreadLocal 来存储上下文的,而服务器(如 Tomcat)的线程是线程池复用的。请求结束后,线程会被放回线程池,等待处理下一个请求。如果不清除,这个线程的 ThreadLocal 里还保留着上一个用户的认证信息。当下一个请求被分配到这个线程时,新请求会错误地继承上一个用户的身份,导致用户信息串用

6.3 调用UserDetailsServiceImpl.loadUserByUsername的原理

// 该方法会去调用UserDetailsServiceImpl.loadUserByUsername
authentication = authenticationManager.authenticate(authenticationToken);
authenticationManager我们已经在配置类里面讲过,是在那个时候注入的。
    /**
     * 身份验证实现
     */
    @Bean
    public AuthenticationManager authenticationManager()
    {
        DaoAuthenticationProvider daoAuthenticationProvider = new DaoAuthenticationProvider();
        daoAuthenticationProvider.setUserDetailsService(userDetailsService);
        daoAuthenticationProvider.setPasswordEncoder(bCryptPasswordEncoder());
        return new ProviderManager(daoAuthenticationProvider);
    }

打开ProviderManager的authenticate

        //仅展示部分
        for(AuthenticationProvider provider : this.getProviders()) {
            if (provider.supports(toTest)) {
                if (logger.isTraceEnabled()) {
                    Log var10000 = logger;
                    String var10002 = provider.getClass().getSimpleName();
                    ++currentPosition;
                    var10000.trace(LogMessage.format("Authenticating request with %s (%d/%d)", var10002, currentPosition, size));
                }

                try {
                    result = provider.authenticate(authentication);
                    if (result != null) {
                        this.copyDetails(authentication, result);
                        break;
                    }
                } catch (InternalAuthenticationServiceException | AccountStatusException ex) {
                    this.prepareException(ex, authentication);
                    throw ex;
                } catch (AuthenticationException ex) {
                    lastException = ex;
                }
            }

这里在遍历所有provider,并配用provider的authenticate方法。

打开我们配置类中选择的DaoAuthenticationProvider

public class DaoAuthenticationProvider extends AbstractUserDetailsAuthenticationProvider 

此类继承了AbstractUserDetailsAuthenticationProvider,没有重写authenticate方法。

进入AbstractUserDetailsAuthenticationProvider,进行查看,重点看方法里面这句代码。

user = this.retrieveUser(username, (UsernamePasswordAuthenticationToken)authentication);

在DaoAuthenticationProvider重写了retrieveUser方法,我们来查看。

    protected final UserDetails retrieveUser(String username, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
        this.prepareTimingAttackProtection();

        try {
            UserDetails loadedUser = this.getUserDetailsService().loadUserByUsername(username);
            if (loadedUser == null) {
                throw new InternalAuthenticationServiceException("UserDetailsService returned null, which is an interface contract violation");
            } else {
                return loadedUser;
            }
        } catch (UsernameNotFoundException ex) {
            this.mitigateAgainstTimingAttack(authentication);
            throw ex;
        } catch (InternalAuthenticationServiceException ex) {
            throw ex;
        } catch (Exception ex) {
            throw new InternalAuthenticationServiceException(ex.getMessage(), ex);
        }
    }

这里确实在底层调用了UserDetailsService.loadUserByUsername。

七、loadUserByUsername实现

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException
    {
        SysUser user = userService.selectUserByUserName(username);
        if (StringUtils.isNull(user))
        {
            log.info("登录用户:{} 不存在.", username);
            throw new ServiceException(MessageUtils.message("user.not.exists"));
        }
        else if (UserStatus.DELETED.getCode().equals(user.getDelFlag()))
        {
            log.info("登录用户:{} 已被删除.", username);
            throw new ServiceException(MessageUtils.message("user.password.delete"));
        }
        else if (UserStatus.DISABLE.getCode().equals(user.getStatus()))
        {
            log.info("登录用户:{} 已被停用.", username);
            throw new ServiceException(MessageUtils.message("user.blocked"));
        }

        passwordService.validate(user);

        return createLoginUser(user);
    }
  • 加载用户信息,通过 userService.selectUserByUserName(username) 从数据库中查询该用户名对应的用户

  • 校验用户状态,如果用户的删除标记为已删除,记录日志并抛出 “用户已删除” 的异常。如果用户状态为已停用,记录日志并抛出 “用户已被停用” 的异常。

  • 密码有效性校

  • 返回登录用户对象

7.1 密码校验

进入SysPasswordService

    public void validate(SysUser user)
    {
        Authentication usernamePasswordAuthenticationToken = AuthenticationContextHolder.getContext();
        String username = usernamePasswordAuthenticationToken.getName();
        String password = usernamePasswordAuthenticationToken.getCredentials().toString();

        Integer retryCount = redisCache.getCacheObject(getCacheKey(username));

        if (retryCount == null)
        {
            retryCount = 0;
        }

        if (retryCount >= Integer.valueOf(maxRetryCount).intValue())
        {
            throw new UserPasswordRetryLimitExceedException(maxRetryCount, lockTime);
        }

        if (!matches(user, password))
        {
            retryCount = retryCount + 1;
            redisCache.setCacheObject(getCacheKey(username), retryCount, lockTime, TimeUnit.MINUTES);
            throw new UserPasswordNotMatchException();
        }
        else
        {
            clearLoginRecordCache(username);
        }
    }

    public boolean matches(SysUser user, String rawPassword)
    {
        return SecurityUtils.matchesPassword(rawPassword, user.getPassword());
    }

    public void clearLoginRecordCache(String loginName)
    {
        if (redisCache.hasKey(getCacheKey(loginName)))
        {
            redisCache.deleteObject(getCacheKey(loginName));
        }
    }
  • 从 AuthenticationContextHolder 中获取当前用户的认证信息

  • 从 Redis 缓存中读取该用户的密码错误重试次数

  • 如果当前重试次数已经达到或超过 maxRetryCount,就直接抛出 “密码重试次数超限” 的异常,阻止用户继续尝试。

  • 校验密码是否正确

    • 调用 matches(user, password) 方法,将用户输入的密码和数据库中存储的加密密码进行比对。
    • 如果密码错误:重试次数加 1,更新到 Redis 缓存并设置过期时间,然后抛出 “密码不匹配” 的异常。
    • 如果密码正确:清除该用户在 Redis 中的重试次数记录,让计数归零。

进入SecurityUtils查看校验密码方法

    /**
     * 判断密码是否相同
     *
     * @param rawPassword 真实密码
     * @param encodedPassword 加密后字符
     * @return 结果
     */
    public static boolean matchesPassword(String rawPassword, String encodedPassword)
    {
        BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
        return passwordEncoder.matches(rawPassword, encodedPassword);
    }

这个用的就是我们配置类里面用的BCryptPasswordEncoder

7.2 创建用户

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

打开SysPermissionService

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

这里基于的就是RBAC权限模型,一个用户关联多个角色,一个角色对应多个权限,基于用户查询对应角色,再根据角色查询对应权限。

    /**
     * 根据用户ID查询权限
     * 
     * @param userId 用户ID
     * @return 权限列表
     */
    @Override
    public Set<String> selectRolePermissionByUserId(Long userId)
    {
        List<SysRole> perms = roleMapper.selectRolePermissionByUserId(userId);
        Set<String> permsSet = new HashSet<>();
        for (SysRole perm : perms)
        {
            if (StringUtils.isNotNull(perm))
            {
                permsSet.addAll(Arrays.asList(perm.getRoleKey().trim().split(",")));
            }
        }
        return permsSet;
    }

这里用了Set是因为用户对应的角色之间可能会有重复,关于用户发送请求的权限校验我们放到用户登录篇来说。

八、生成令牌

// 生成token
return tokenService.createToken(loginUser);

进入TokenService。

    /**
     * 创建令牌
     * 
     * @param loginUser 用户信息
     * @return 令牌
     */
    public String createToken(LoginUser loginUser)
    {
        String token = IdUtils.fastUUID();
        loginUser.setToken(token);
        setUserAgent(loginUser);
        refreshToken(loginUser);

        Map<String, Object> claims = new HashMap<>();
        claims.put(Constants.LOGIN_USER_KEY, token);
        claims.put(Constants.JWT_USERNAME, loginUser.getUsername());
        return createToken(claims);
    }

8.1 uuid

目前我们有两种uuid

  • 一个是我们用户唯一标识uuid,会存放再token中,也是我们redis中存放loginuser的键,令牌失效了,这个键也就被删除了,下次生成新令牌再有新的uuid。
  • 一个是我们验证码的uuid,当在登录页面时会发送请求生成验证码和验证码的uuid,当验证码使用后就直接删除。

8.2 生成代理对象

    /**
     * 设置用户代理信息
     * 
     * @param loginUser 登录信息
     */
    public void setUserAgent(LoginUser loginUser)
    {
        String userAgent = ServletUtils.getRequest().getHeader("User-Agent");
        String ip = IpUtils.getIpAddr();
        loginUser.setIpaddr(ip);
        loginUser.setLoginLocation(AddressUtils.getRealAddressByIP(ip));
        loginUser.setBrowser(UserAgentUtils.getBrowser(userAgent));
        loginUser.setOs(UserAgentUtils.getOperatingSystem(userAgent));
    }

这里的就是一些用户登录ip的信息,用来记录用户登录日志用的。

8.3 刷新令牌

refreshToken(loginUser);

这里的操作逻辑和配置篇的Jwt过滤器的操作流程一样,就不再赘述了。

九、图解

十、后续内容

动态菜单篇

若依框架 SpringSecurity :动态菜单源码全拆解(附图解)-CSDN博客

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐