Skip to content

📄介绍

近期项目新增了“用户密码每90天强制轮换”的安全合规需求。尽管系统已具备密码重置与主动修改功能,但均无法直接支撑该策略:批量强制重置会严重损害用户体验,而依赖用户自觉又缺乏约束力。为此,我们设计了一套方案,强制用户在密码到期后修改一次密码,现将实现思路记录如下。

❓为什么需要密码轮换

密码一但泄露,可以被攻击者长期持有并窃取系统数据,实现密码轮换之后,可以缩短密码的有效暴露窗口,即便密码已经泄露,轮换机制也可以确保旧凭证在有限的时间内失效,降低密码泄露带来的损失。

🔥解决思路

密码轮换第一个问题是我们需要知道密码轮换的间隔时间。第二个问题是要知道用户上次修改密码的时间,用于判断是否需要修改密码。第三个问题是在什么时机触发轮换校验机制。第四个问题是用户登录之后发现需要修改密码,要用什么手段来防止绕过修改密码操作,并防止非法调用业务API接口。

综合以上问题,我们可以在我们的配置文件中增加一个间隔时间配置,并且修改我们的用户表结构,增加两个字段,上次密码修改时间。在用户执行登录操作之后,需要给客户端推送一个参数,表示是否需要修改密码,如果需要修改则前端跳转到修改密码的操作页面。为了防止过期的密码获取到的token也可以无限制的调取接口,在后端服务中也可以添加一个过滤器或者拦截器,如果密码超期未修改仅能调用系统登录、修改密码相关的接口。在前端还需要保证登录之后通过刷新或者改变路由地址的方式,来直接进入首页。

🛬实操代码

根据解决思路,本次选择的方案是将过期时间配置在配置文件中,并且在用户表新增密码修改时间字段,修改获取用户信息接口,在接口中返回是否需要修改密码的参数,最后添加全局的拦截器,保证密码到期未修改时无法访问其他的业务接口。

本次实操代码基于Ruoyi-Vue-Plus框架开发。框架地址:RuoYi-Vue-Plus,框架文档地址:Plus文档

运行项目

从仓库拉取代码,然后参考框架文档地址将他运行起来

修改配置文件

在application.yml文件中添加配置:

yaml
password:
  # 密码轮换周期,单位:天
  update-interval: 90

读取配置的轮换周期

Spring Boot中可以通过@Value注解、@ConfigurationProperties注解等方式来获取配置文件中的参数,我这里采用@ConfigurationProperties来获取参数。

可参考:SpringBoot配置文件读取的各种方式

创建一个通用的配置类,用来承接配置文件中的参数。

java
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.stereotype.Component;

@EnableConfigurationProperties
@ConfigurationProperties(prefix = "password")
@Component
@Data
public class PasswordConfig {

    /**
     * 密码轮换周期
     */
    private Integer updateInterval;
}

这样就创建了一个专门用于承接密码配置的配置类,后续有其他的配置也可以放在这个类中,注入到需要使用的spring bean中即可。

修改数据库表与JavaBean对象

给字段值添加默认值,防止新创建的数据没有上次密码更新时间导致判断逻辑异常。

sql
alter table sys_user add column pwd_last_update_time datetime default current_timestamp not null comment '上次更新密码时间';

SysUser.Java

java
package org.dromara.system.domain;

import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
import org.dromara.common.core.constant.SystemConstants;
import org.dromara.common.mybatis.core.domain.BaseEntity;

import java.time.LocalDateTime;
import java.util.Date;

/**
 * 用户对象 sys_user
 *
 * @author Lion Li
 */

@Data
@NoArgsConstructor
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user")
public class SysUser extends BaseEntity {

    /**
     * 用户ID
     */
    @TableId(value = "user_id")
    private Long userId;

    /**
     * 部门ID
     */
    private Long deptId;

    /**
     * 用户账号
     */
    private String userName;

    /**
     * 用户昵称
     */
    private String nickName;

    /**
     * 用户类型(sys_user系统用户)
     */
    private String userType;

    /**
     * 用户邮箱
     */
    private String email;

    /**
     * 手机号码
     */
    private String phoneNumber;

    /**
     * 用户性别
     */
    private String gender;

    /**
     * 用户头像
     */
    private Long avatar;

    /**
     * 密码
     */
    @TableField(
        insertStrategy = FieldStrategy.NOT_EMPTY,
        updateStrategy = FieldStrategy.NOT_EMPTY,
        whereStrategy = FieldStrategy.NOT_EMPTY
    )
    private String password;

    /**
     * 账号状态(0正常 1停用)
     */
    private String status;

    /**
     * 删除标志(0代表存在 1代表删除)
     */
    @TableLogic
    private String delFlag;

    /**
     * 最后登录IP
     */
    private String loginIp;

    /**
     * 最后登录时间
     */
    private LocalDateTime loginDate;

    /**
     * 备注
     */
    private String remark;

    /**
     * 密码最后修改时间
     */
    private Date pwdLastUpdateTime;

    /**
     * 使用用户ID构造系统用户对象。
     *
     * @param userId 用户ID
     */
    public SysUser(Long userId) {
        this.userId = userId;
    }

    /**
     * 判断当前用户是否为超级管理员。
     *
     * @return true 是超级管理员 false 不是超级管理员
     */
    public boolean isSuperAdmin() {
        return SystemConstants.SUPER_ADMIN_USER_ID.equals(this.userId);
    }

}

SysUserVo.Java

java
package org.dromara.system.domain.vo;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.dromara.common.sensitive.annotation.Sensitive;
import org.dromara.common.sensitive.core.SensitiveStrategy;
import org.dromara.common.translation.annotation.Translation;
import org.dromara.common.translation.constant.TransConstant;
import org.dromara.system.domain.SysUser;

import java.io.Serial;
import java.io.Serializable;
import java.time.LocalDateTime;
import java.util.Date;
import java.util.List;


/**
 * 用户信息视图对象 sys_user
 *
 * @author Michelle.Chung
 */
@Data
@AutoMapper(target = SysUser.class)
public class SysUserVo implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    /**
     * 用户ID
     */
    private Long userId;

    /**
     * 部门ID
     */
    private Long deptId;

    /**
     * 用户账号
     */
    private String userName;

    /**
     * 用户昵称
     */
    private String nickName;

    /**
     * 用户类型(sys_user系统用户)
     */
    private String userType;

    /**
     * 用户邮箱
     */
    @Sensitive(strategy = SensitiveStrategy.EMAIL, perms = "system:user:edit")
    private String email;

    /**
     * 手机号码
     */
    @Sensitive(strategy = SensitiveStrategy.PHONE, perms = "system:user:edit")
    private String phoneNumber;

    /**
     * 用户性别(0男 1女 2未知)
     */
    private String gender;

    /**
     * 头像 OSS ID
     */
    private Long avatar;

    /**
     * 头像地址
     */
    @Translation(type = TransConstant.OSS_ID_TO_URL, mapper = "avatar")
    private String avatarUrl;

    /**
     * 密码
     */
    @JsonIgnore
    @JsonProperty
    private String password;

    /**
     * 账号状态(0正常 1停用)
     */
    private String status;

    /**
     * 最后登录IP
     */
    private String loginIp;

    /**
     * 最后登录时间
     */
    private LocalDateTime loginDate;

    /**
     * 备注
     */
    private String remark;

    /**
     * 创建时间
     */
    private LocalDateTime createTime;

    /**
     * 更新时间
     */
    private LocalDateTime updateTime;

    /**
     * 部门名
     */
    @Translation(type = TransConstant.DEPT_ID_TO_NAME, mapper = "deptId")
    private String deptName;

    /**
     * 角色对象
     */
    private List<SysRoleVo> roles;

    /**
     * 角色组
     */
    private Long[] roleIds;

    /**
     * 岗位组
     */
    private Long[] postIds;

    /**
     * 数据权限 当前角色ID
     */
    private Long roleId;

    /**
     * 密码最后修改时间
     */
    private Date pwdLastUpdateTime;

    /**
     * 是否需要修改密码
     */
    private Boolean needResetPwd;
}

修改获取个人信息的逻辑

Ruoyi-Vue-Plus框架前端页面刷新的时候会发请求获取用户信息,这个获取用户信息的接口非常适合用来判断用户是否在规定范围内修改过密码。

首先在SysUserController中注入配置类。

java
private final PasswordConfig passwordConfig;

添加用户判断是否需要改密码的逻辑

java
// CacheNames文件中
/**
 * 需要重置密码用户
 */
String SYS_USER_NEED_RESET_PWD = "reset_pwd:";
java
/**
 * 获取用户信息
 *
 * @return 当前登录用户信息、角色与权限集合
 */
@GetMapping("/getInfo")
public R<UserInfoVo> getInfo() {
    UserInfoVo userInfoVo = new UserInfoVo();
    LoginUser loginUser = LoginHelper.getLoginUser();

    SysUserVo user = DataPermissionHelper.ignore(() -> userService.selectUserById(loginUser.getUserId()));
    if (ObjectUtil.isNull(user)) {
        return R.fail("没有权限访问用户数据!");
    }
    LocalDateTime nextUpdateTime = user.getPwdLastUpdateTime()
        .toInstant()
        .atZone(ZoneId.systemDefault())
        .toLocalDateTime()
        .plusDays(passwordConfig.getUpdateInterval());
    if (nextUpdateTime.isBefore(LocalDateTime.now())) {
        // 下次修改密码的时间在现在之前,说明需要修改密码
        user.setNeedResetPwd(true);
        // 往redis中写入一个缓存,密码待重置
        CacheUtils.put(CacheNames.SYS_USER_NEED_RESET_PWD, user.getUserId(), true);
    } else {
        user.setNeedResetPwd(false);
    }

    userInfoVo.setUser(user);
    userInfoVo.setPermissions(loginUser.getMenuPermission());
    userInfoVo.setRoles(loginUser.getRolePermission());
    return R.ok(userInfoVo);
}

修改重置密码的逻辑

重置密码时需要同步修改密码上次更新时间的字段,并清除该用户的“密码待重置”缓存状态

java
/**
 * 重置用户密码
 *
 * @param userId   用户ID
 * @param password 密码
 * @return 结果
 */
@Override
public int resetUserPwd(Long userId, String password) {
    int cnt = userMapper.lambda()
        .set(SysUser::getPassword, password)
        // 同步修改密码更新时间
        .set(SysUser::getPwdLastUpdateTime, DateUtils.now())
        .eq(SysUser::getUserId, userId)
        .updateCount();
    if (cnt > 0) {
        // 清除缓存
        CacheUtils.evict(CacheNames.SYS_USER_NEED_RESET_PWD, userId);
    }
    return cnt;
}