1. 概述

本文将带你实现一个常见的功能:用户在注册并登录后,可以自行修改密码。

这个功能看似简单,但涉及前端交互、后端安全校验、权限控制和测试验证等多个环节。我们一步步来,避免踩坑。

2. 前端页面:修改密码表单

先看一个极简的前端页面,包含旧密码、新密码和确认密码三个输入框:

<html>
<body>
<div id="errormsg" style="display:none"></div>
<div>
    <input id="oldpass" name="oldpassword" type="password" placeholder="旧密码" />
    <input id="pass" name="password" type="password" placeholder="新密码" />
    <input id="passConfirm" type="password" placeholder="确认新密码" />              
    <span id="error" style="display:none; color:red">密码不匹配</span>
                    
   <button type="submit" onclick="savePass()">修改密码</button>
</div>
 
<script src="jquery.min.js"></script>
<script type="text/javascript">
var serverContext = [[@{/}]];
function savePass(){
    var pass = $("#pass").val();
    var valid = pass == $("#passConfirm").val();
    if(!valid) {
      $("#error").show();
      return;
    }
    $.post(serverContext + "user/updatePassword",
      {password: pass, oldpassword: $("#oldpass").val()} ,
      function(data){
        window.location.href = serverContext +"/home.html?message="+data.message;
    })
    .fail(function(data) {
        $("#errormsg").show().html(data.responseJSON.message);
    });
}
</script> 
</body>
</html>

✅ 关键点说明:

  • 使用 jQuery 发起 POST 请求到 /user/updatePassword
  • 前端做了简单的密码一致性校验,避免无效请求
  • 错误信息通过 #errormsg 展示,来自后端的 JSON 响应
  • serverContext 是 Thymeleaf 模板中的上下文路径,确保 URL 正确

⚠️ 注意:生产环境建议使用更健壮的前端框架(如 Vue/React)和表单验证库,但这不是本文重点。

3. 后端接口:更新用户密码

接下来是核心的后端逻辑,对应上面的 POST 请求:

@PostMapping("/user/updatePassword")
@PreAuthorize("hasRole('READ_PRIVILEGE')")
public GenericResponse changeUserPassword(Locale locale, 
  @RequestParam("password") String password, 
  @RequestParam("oldpassword") String oldPassword) {
    User user = userService.findUserByEmail(
      SecurityContextHolder.getContext().getAuthentication().getName());
    
    if (!userService.checkIfValidOldPassword(user, oldPassword)) {
        throw new InvalidOldPasswordException();
    }
    userService.changeUserPassword(user, password);
    return new GenericResponse(messages.getMessage("message.updatePasswordSuc", null, locale));
}

✅ 核心要点解析:

  • @PreAuthorize("hasRole('READ_PRIVILEGE')")
    保证只有登录且具备对应权限的用户才能访问该接口,防止未授权调用 ❌
  • ✅ 通过 SecurityContextHolder 获取当前登录用户的邮箱(即用户名)
  • ✅ 调用 checkIfValidOldPassword 验证旧密码是否正确(通常会用 PasswordEncoder 匹配加密后的密码)
  • ✅ 密码正确后调用 changeUserPassword 更新为新密码(自动加密存储)
  • ✅ 成功返回本地化提示消息,失败抛出自定义异常(由全局异常处理器捕获返回 400)

⚠️ 踩坑提醒:

  • 不要直接比较明文密码!必须使用 PasswordEncoder.matches(rawPassword, encodedPassword)
  • @RequestParam 参数名必须与前端一致,否则收不到值
  • 异常建议抛出而非手动返回错误码,便于统一处理

4. 接口测试:覆盖关键场景

光写代码不够,必须有自动化测试保底。以下是基于 TestNG + RestAssured 的 API 测试用例。

4.1 测试配置与初始化

@ExtendWith(SpringExtension.class)
@ContextConfiguration(
  classes = { ConfigTest.class, PersistenceJPAConfig.class }, 
  loader = AnnotationConfigContextLoader.class)
public class ChangePasswordApiTest {
    private final String URL_PREFIX = "http://localhost:8080/"; 
    private final String URL = URL_PREFIX + "/user/updatePassword";
    
    @Autowired
    private UserRepository userRepository;

    @Autowired
    private PasswordEncoder passwordEncoder;

    FormAuthConfig formConfig = new FormAuthConfig(
      URL_PREFIX + "/login", "username", "password");

    @BeforeEach
    public void init() {
        User user = userRepository.findByEmail("testuser@example.com");
        if (user == null) {
            user = new User();
            user.setFirstName("Test");
            user.setLastName("Test");
            user.setPassword(passwordEncoder.encode("test"));
            user.setEmail("testuser@example.com");
            user.setEnabled(true);
            userRepository.save(user);
        } else {
            user.setPassword(passwordEncoder.encode("test"));
            userRepository.save(user);
        }
    }
}

📌 初始化逻辑说明:

  • 使用 @BeforeEach 确保每次测试前数据干净
  • 用户邮箱为 testuser@example.com,初始密码为 "test"(已加密)
  • FormAuthConfig 用于模拟表单登录认证

4.2 场景一:登录用户正常修改密码 ✅

@Test
public void givenLoggedInUser_whenChangingPassword_thenCorrect() {
    RequestSpecification request = RestAssured.given().auth()
      .form("testuser@example.com", "test", formConfig);

    Map<String, String> params = new HashMap<>();
    params.put("oldpassword", "test");
    params.put("password", "newtest");

    Response response = request.with().params(params).post(URL);

    assertEquals(200, response.statusCode());
    assertTrue(response.body().asString().contains("Password updated successfully"));
}

📌 验证点:

  • 登录状态下提交正确旧密码
  • 期望状态码 200,响应包含成功提示

4.3 场景二:旧密码错误 ❌

@Test
public void givenWrongOldPassword_whenChangingPassword_thenBadRequest() {
    RequestSpecification request = RestAssured.given().auth()
      .form("testuser@example.com", "test", formConfig);

    Map<String, String> params = new HashMap<>();
    params.put("oldpassword", "abc");
    params.put("password", "newtest");

    Response response = request.with().params(params).post(URL);

    assertEquals(400, response.statusCode());
    assertTrue(response.body().asString().contains("Invalid Old Password"));
}

📌 验证点:

  • 提交错误的旧密码
  • 期望返回 400,并提示“旧密码无效”

4.4 场景三:未登录用户尝试修改密码 ⚠️

@Test
public void givenNotAuthenticatedUser_whenChangingPassword_thenRedirect() {
    Map<String, String> params = new HashMap<>();
    params.put("oldpassword", "abc");
    params.put("password", "xyz");

    Response response = RestAssured.with().params(params).post(URL);

    assertEquals(302, response.statusCode());
    assertFalse(response.body().asString().contains("Password updated successfully"));
}

📌 验证点:

  • 未携带认证信息直接调用接口
  • 由于 @PreAuthorize 拦截,应跳转登录页(302 Redirect)
  • 响应体不应包含成功提示

✅ 总结测试策略:

场景 输入 预期结果
正常流程 正确旧密码 200 + 成功提示
旧密码错误 错误旧密码 400 + 错误提示
未认证访问 无登录态 302 跳转

这样才算覆盖了主要边界情况。

5. 小结

本文实现了一个完整且安全的“修改密码”功能,涵盖:

  • 前端表单与 JS 交互
  • 后端权限控制与密码校验
  • 多场景自动化测试

整个流程简单粗暴但实用,适合集成到现有 Spring Security 项目中。

📌 生产环境可扩展方向:

  • 添加密码强度校验(正则/策略模式)
  • 记录密码修改日志
  • 增加二次验证(如邮箱验证码)
  • 支持密码历史防止重复使用

完整代码示例可参考 GitHub 项目:https://github.com/Baeldung/spring-security-registration
这是一个基于 Eclipse 的 Spring 项目,导入即可运行。


原始标题:Baeldung