Featured image of post 【Ruoyi管理后台】登录强制修改密码

【Ruoyi管理后台】登录强制修改密码

介绍在 Ruoyi 管理后台如何加入强制用户修改密码,在用户登录时检测到需要重置密码时,强制其跳转至密码重置页面进行密码重置。主要通过解决登录凭证 token 的设置与清理,实现强制跳转,并防止用户绕过直接返回登录到后台。

作为一个后台管理系统,用户的登录信息安全是很重要的,所以避免用户账号密码被破解泄露,一般都会采取强制用户在一段时间内需要重新修改更新一次密码,否则就不能正常登录系统。 本教程使用的若依前后端分离版本 v3.8.8

视频教程 https://bit.ly/4fUrwcL 教程markdown文档,关注 [大卷学长] 公众号回复 [ry1118]

一、后端项目调整

我们需要在用户表增加一个字段,用于标记用户最近一次修改密码的时间。

调整表结构

当前系统中的用户表并不支持记录用户的最后修改密码的时间,所以我们需要增加一个的字段来记录。

用户表 sys_user 加入 pwd_time 字段

每次用户更新密码时,都需要更新此字段为当前系统时间。

表结构调整

调整数据表映射文件 SysUserMapper.xml

resultMap 中增加 pwd_time 字段

mapper调整_01

查询语句中,增加 pwd_time 字段

mapper调整_02

重置密码语句中,增加 pwd_time 字段的更新,并设置为当前时间

mapper调整_03

调整实体对象 SysUser

ruoyi-common 项目 com.ruoyi.common.core.domain.entity.SysUser

添加 pwdTime 字段,注意要添加对应的 get/set 方法。

entity调整_01

调整登录接口

为什么要在登录接口这个地方调整,是因为后面的重置密码接口是需要登录后的 token 才能够调用成功,否则会提示登录已过期。

SysLoginService

ruoyi-framework 项目 com.ruoyi.framework.web.service.SysLoginService

新增加入判断用户是否已超过三个月没修改过密码的方法,这里具体是多长时间,可以自定义。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public boolean isPwdExpire(String username) {

    SysUser sysUser = userService.selectUserByUserName(username);
    Date pwdTime = sysUser.getPwdTime();

    LocalDate pwdDate = pwdTime.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
    // 获取当前日期
    LocalDate currentDate = LocalDate.now();
    // 计算日期差异
    Period period = Period.between(pwdDate, currentDate);
    // 检查月份差异是否大于等于三个月
    if (period.toTotalMonths() >= 3) {
        return true;
    }

    return false;
}

SysLoginController

ruoyi-admin 项目 com.ruoyi.web.controller.system.SysLoginController

在 login 方法内,加入上面的方法判断,如果是密码过期了,就返回 res_code=1001,用于页面判断当前登录用户是否需要重置密码。同时出于安全的考虑,还使用了一些校验机制,生成一个sign标记,并设置在 redis 缓存里,用于重置密码时校验。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//加入 redis 缓存工具类
@Autowired
private RedisCache redisCache;

/**
 * 登录方法
 * 
 * @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());
    //加入密码过期判断
    boolean pwdExpire = loginService.isPwdExpire(loginBody.getUsername());
    if (pwdExpire) {
        ajax.put("res_code", 1001);
        String signKey = CacheConstants.RESET_SIGN_KEY + loginBody.getUsername();
        String signCode = IdUtils.fastSimpleUUID();
        redisCache.setCacheObject(signKey, signCode, Constants.RESET_EXPIRATION, TimeUnit.MINUTES);
        ajax.put("reset_sign",signCode);
    }

    ajax.put(Constants.TOKEN, token);
    return ajax;
}

ruoyi-common 项目 com.ruoyi.common.constant.Constants 加入常量。

1
2
3
4
/**
 * 密码重置有效期(分钟)
 */
public static final Integer RESET_EXPIRATION = 5;

ruoyi-common 项目 com.ruoyi.common.constant.CacheConstants 加入常量。

1
2
3
4
/**
 * 密码重置sign redis key
 */
public static final String RESET_SIGN_KEY = "reset_sign:";

加入重置密码接口

在后台管理系统的个人中心有一个重置密码的地方,对应的也有一个重置密码的接口,但这是在用户已登录能正常进入后台的情况下操作的。

ruoyi-admin 项目 com.ruoyi.web.controller.system.SysProfileControllerupdatePwd 方法。

后台个人中心修改密码_01

而我们是需要在用户登录的时候,处理用户重置密码的流程,所以我们需要另外写一个接口来处理登录时的密码重置。

新增修改密码的请求实体对象 ResetBody

ruoyi-common 项目 com.ruoyi.common.core.domain.model

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
public class ResetBody {

    /**
     * 用户名
     */
    private String username;

    private String oldPassword;

    private String newPassword;

    private String confirmPassword;

    private String code;

    private String sign;

    private String uuid;

    public String getUsername() {
        return username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

    public String getOldPassword() {
        return oldPassword;
    }

    public void setOldPassword(String oldPassword) {
        this.oldPassword = oldPassword;
    }

    public String getNewPassword() {
        return newPassword;
    }

    public void setNewPassword(String newPassword) {
        this.newPassword = newPassword;
    }

    public String getConfirmPassword() {
        return confirmPassword;
    }

    public void setConfirmPassword(String confirmPassword) {
        this.confirmPassword = confirmPassword;
    }

    public String getCode() {
        return code;
    }

    public void setCode(String code) {
        this.code = code;
    }

    public String getSign() {
        return sign;
    }

    public void setSign(String sign) {
        this.sign = sign;
    }

    public String getUuid() {
        return uuid;
    }

    public void setUuid(String uuid) {
        this.uuid = uuid;
    }
}

新增校验验证码的方法

ruoyi-framework 项目 com.ruoyi.framework.web.service.SysLoginService 添加新的 validateCaptcha 方法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
    /**
     * 校验验证码
     *
     * @param username 用户名
     * @param code 验证码
     * @param uuid 唯一标识
     * @param delete 是否删除缓存
     * @return 结果
     */
    public void validateCaptcha(String username, String code, String uuid, boolean delete)
    {
        String verifyKey = CacheConstants.CAPTCHA_CODE_KEY + uuid;
        String captcha = redisCache.getCacheObject(verifyKey);

        if (delete) {
            redisCache.deleteObject(verifyKey);
        }

        if (captcha == null)
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.expire")));
            throw new CaptchaExpireException();
        }
        if (!code.equalsIgnoreCase(captcha))
        {
            AsyncManager.me().execute(AsyncFactory.recordLogininfor(username, Constants.LOGIN_FAIL, MessageUtils.message("user.jcaptcha.error")));
            throw new CaptchaException();
        }
    }

重置密码接口

ruoyi-admin 项目 com.ruoyi.web.controller.system.SysProfileController 添加 resetPwd 接口。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
//加入 SysLoginService
@Autowired
private SysLoginService sysLoginService;

//加入 redis 缓存工具类
@Autowired
private RedisCache redisCache;

/**
 * 重置密码
 */
@Log(title = "个人信息", businessType = BusinessType.UPDATE)
@PostMapping("/resetPwd")
public AjaxResult resetPwd(@RequestBody ResetBody resetBody)
{

    String username = resetBody.getUsername();
    String sign = resetBody.getSign();

    String signKey = CacheConstants.RESET_SIGN_KEY + username;

    String cacheSign = redisCache.getCacheObject(signKey);
    if (StringUtils.isEmpty(cacheSign)) {
        return AjaxResult.error("链接已失效");
    }

    if (!cacheSign.equals(sign)) {
        return AjaxResult.error("sign有误");
    }

    String code = resetBody.getCode();
    String uuid = resetBody.getUuid();

    sysLoginService.validateCaptcha(username, code, uuid, false);

    SysUser sysUser = userService.selectUserByUserName(username);
    if (sysUser == null) {
        return AjaxResult.error("用户不存在");
    }

    String oldPassword = resetBody.getOldPassword();
    String password = sysUser.getPassword();

    if (!SecurityUtils.matchesPassword(oldPassword, password)) {
        return AjaxResult.error("修改密码失败,旧密码错误");
    }

    String newPassword = resetBody.getNewPassword();

    if (userService.resetUserPwd(username, SecurityUtils.encryptPassword(newPassword)) > 0)
    {
        LoginUser loginUser = getLoginUser();
        // 删除用户缓存记录
        tokenService.delLoginUser(loginUser.getToken());
        // 删除缓存
        redisCache.deleteObject(signKey);
        // 前端重定向到 login 页面
        return AjaxResult.success();
    }

    return AjaxResult.error("修改密码异常,请联系管理员");
}

方法中处理了一些校验,如上面说到的sign标记,还有页面上的图形验证码等,增加安全性。重置密码生成后,需要清除用户缓存记录,用于使用户必须重新登录。

二、前端项目调整

调整用户登录模型

文件 src/store/modules/user.js

这里需要调用 login 接口后的返回信息,带回到登录页面,用于后续的操作。

前端user模型_01

调整用户 API 接口

文件 src/api/system/user.js

加入后端的重置密码接口

1
2
3
4
5
6
7
8
// 用户密码重置
export function resetUserProfilePwd(data) {
  return request({
    url: '/system/user/profile/resetPwd',
    method: 'post',
    data: data
  })
}

重置密码页面

新增文件 src/views/reset.vue

重点在于 handleReset 函数,可参考注释说明。密码规则为 长度12-20位,必须包含数字、大小写字母、符号,可自定义调整。

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
<template>
  <div class="register">
    <el-form ref="resetForm" :model="resetForm" :rules="resetRules" class="register-form">
      <h3 class="title">修改密码</h3>
 
      <el-form-item label="旧密码" prop="oldPassword">
        <el-input v-model="resetForm.oldPassword" placeholder="请输入旧密码" type="password" show-password/>
      </el-form-item>
      <el-form-item label="新密码" prop="newPassword">
        <el-input v-model="resetForm.newPassword" placeholder="12位由数字、大小写字母、符号组成" type="password" show-password/>
      </el-form-item>
      <el-form-item label="确认密码" prop="confirmPassword">
        <el-input v-model="resetForm.confirmPassword" placeholder="请确认密码" type="password" show-password/>
      </el-form-item>
 
      <el-form-item prop="code" v-if="captchaOnOff">
        <el-input
          v-model="resetForm.code"
          auto-complete="off"
          placeholder="验证码"
          style="width: 63%"
        >
          <svg-icon slot="prefix" icon-class="validCode" class="el-input__icon input-icon" />
        </el-input>
        <div class="register-code">
          <img :src="codeUrl" @click="getCode" class="register-code-img"/>
        </div>
      </el-form-item>
      <el-form-item style="width:100%;">
        <el-button
          :loading="loading"
          size="medium"
          type="primary"
          style="width:100%;"
          @click.native.prevent="handleReset"
        >
          <span v-if="!loading">确认修改</span>
        </el-button>
      </el-form-item>
    </el-form>
    <!--  底部  -->
    <div class="el-register-footer">
      <span>Copyright © 2018-2021 ruoyi.vip All Rights Reserved.</span>
    </div>
  </div>
</template>
 
<script>
import { getCodeImg } from "@/api/login";
import { resetUserProfilePwd } from "@/api/system/user";
import { setToken } from '@/utils/auth'
 
export default {
  name: "Reset",
  data() {
    const equalToPassword = (rule, value, callback) => {
      if (this.resetForm.newPassword !== value) {
        callback(new Error("两次输入的密码不一致"));
      } else {
        const reg = /^(?![A-z0-9]+$)(?![A-z~!@#$%^&*()_+]+$)(?![0-9~!@#$%^&*()_+]+$)([A-z0-9~!@#$%^&*()_+]{12,})/g
        if (!reg.test(value)) {
          callback(new Error("输入的密码必须包含数字、大小写字母、符号"));
        }
        callback();
      }
    };
    return {
      codeUrl: "",
      resetForm: {
        username: "",
        oldPassword: "",
        newPassword: "",
        confirmPassword: "",
        code: "",
        sign: "",
        uuid: ""
      },
      resetRules: {
        oldPassword: [
          { required: true, trigger: "blur", message: "旧密码不能为空" },
        ],
        newPassword: [
          { required: true, message: "新密码不能为空", trigger: "blur" },
          { min: 12, max: 20, message: "长度在 12 到 20 个字符", trigger: "blur" }
        ],
        confirmPassword: [
          { required: true, message: "确认密码不能为空", trigger: "blur" },
          { required: true, validator: equalToPassword, trigger: "blur" }
        ],
        code: [{ required: true, trigger: "change", message: "请输入验证码" }]
      },
      loading: false,
      captchaOnOff: true
    };
  },
  created() {
    this.getCode();
  },
  mounted() {
    // 先清除token,防止回退后能直接登录,从而绕过强制重置密码的逻辑
    setToken('');
    // 获取当前链接的参数
    const params = this.$route.query;
    this.resetForm.sign = params.sign;
    this.resetForm.username = params.username;
    this.token = params.token;
  },
  methods: {
    getCode() {
      getCodeImg().then(res => {
        this.captchaOnOff = res.captchaOnOff === undefined ? true : res.captchaOnOff;
        if (this.captchaOnOff) {
          this.codeUrl = "data:image/gif;base64," + res.img;
          this.resetForm.uuid = res.uuid;
        }
      });
    },
    handleReset() {
      this.$refs.resetForm.validate(valid => {
        if (valid) {
          this.loading = true;
          // 获取并设置从登录页拿到的token,调用接口需要登录成功的token,否则会提示过期
          setToken(localStorage.getItem("reset_token"));
          resetUserProfilePwd(this.resetForm).then(res => {
            this.$alert("<font color='red'>修改成功,请重新登录</font>", '系统提示', {
              dangerouslyUseHTMLString: true,
              type: 'success'
            }).then(() => {
              //删除登录成功设置的token
              localStorage.removeItem("reset_token");
              //清除token,强制登录
              setToken("")
              // 跳转到登录页
              this.$router.push("/login");
            }).catch(() => {});
          }).catch(() => {
            //调用接口异常时,这里也需要清理token,防止返回后自动登录
            setToken("")
            this.loading = false;
            if (this.captchaOnOff) {
              this.getCode();
            }
          })
        }
      });
    }
  }
};
</script>
 
<style rel="stylesheet/scss" lang="scss">
.register {
  display: flex;
  justify-content: center;
  align-items: center;
  height: 100%;
  background-image: url("../assets/images/login-background.jpg");
  background-size: cover;
}
.title {
  margin: 0px auto 30px auto;
  text-align: center;
  color: #707070;
}
 
.register-form {
  border-radius: 6px;
  background: #ffffff;
  width: 400px;
  padding: 25px 25px 5px 25px;
  .el-input {
    height: 38px;
    input {
      height: 38px;
    }
  }
  .input-icon {
    height: 39px;
    width: 14px;
    margin-left: 2px;
  }
}
.register-tip {
  font-size: 13px;
  text-align: center;
  color: #bfbfbf;
}
.register-code {
  width: 33%;
  height: 38px;
  float: right;
  img {
    cursor: pointer;
    vertical-align: middle;
  }
}
.el-register-footer {
  height: 40px;
  line-height: 40px;
  position: fixed;
  bottom: 0;
  width: 100%;
  text-align: center;
  color: #fff;
  font-family: Arial;
  font-size: 12px;
  letter-spacing: 1px;
}
.register-code-img {
  height: 38px;
}
</style>

调整路由

文件 src/router/index.js

加入跳转到重置密码页面的路由配置。

1
2
3
4
5
  {
    path: '/reset',
    component: (resolve) => require(['@/views/reset'], resolve),
    hidden: true
  }

调整用户登录逻辑

文件 src/views/login.vue

调整 handleLogin 函数,加入判断是否需要重置密码的逻辑。

前端用户登录逻辑_01

代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
this.$store.dispatch("Login", this.loginForm).then((res) => {
        if (res.res_code && res.res_code === 1001) {// 判断到后端接口返回的重置密码标识码
          // 先设置token
          localStorage.setItem("reset_token", res.token);
          // 重定向到重置密码页,并带上校验参数
          this.redirect = '/reset?' + 'sign=' + res.reset_sign + '&username=' + this.loginForm.username;
        }
        this.$router.push({ path: this.redirect || "/" }).catch(()=>{});
      }).catch(() => {
        this.loading = false;
        if (this.captchaOnOff) {
          this.getCode();
        }
      });

三、测试

先将普通账号 ry 的密码更新调整为三个月前。

测试_01

接着登录一下账号。

测试_02

页面进行了跳转,来到了重置密码页面。

测试_03

需要校验旧密码,并输入新的密码。

测试_04

修改成功,并提示重新登录。

测试_05

重新登录

测试_06

此时看到 密码更新时间 已经更新

测试_07

本博客所有内容无特殊标注均为大卷学长原创内容,复制请保留原文出处。
Built with Hugo
Theme Stack designed by Jimmy