You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Spring Security跳转异常:邮箱验证链接无法打开设置密码页

问题描述

我要实现用户在邮箱验证阶段设置密码的功能:管理员创建用户账号后,用户会收到包含如下链接的邮件:http://localhost:8080/api/v1/students/set-password.html?token=d9bbd9b6-d94b-4aac-b4fe-756402eeb6ad。用户点击该链接时,邮箱应完成验证并显示set-password.html页面供用户设置密码。但每次访问该链接,都会自动重定向到http://localhost:8080/login.html。我认为SecurityConfiguration配置存在问题,尝试修改了部分配置但未解决,现咨询问题原因、简易重定向方案及优化实现方式。

相关代码如下:

StudentService类的setPassword方法

@Transactional
public boolean setPassword(String token, String password, String confirmPassword) {
if (!password.equals(confirmPassword)) {
throw new IllegalArgumentException("Passwords do not match.");
}

Student student = studentRepository.findByVerificationToken(UUID.fromString(token))
.orElseThrow(() -> new IllegalArgumentException("Invalid or expired token."));

        if (!student.isEmailVerified()) {
            throw new IllegalStateException("Email is not verified.");
        }
    
        student.setPassword(passwordEncoder.encode(password));
        student.setVerificationToken(null); // Invalidate the token after password is set
        studentRepository.save(student);
        return true;
    }

StudentController

package pl.studia.university.controller;

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/v1/students")
@CrossOrigin
public class StudentController {

    private final StudentService studentService;
    
    
    @GetMapping
    public Page<StudentDto> search(@RequestParam(value = "search", required = false) String search, Pageable pageable) {
        return studentService.search(search, pageable);
    }
    
    @PostMapping("/create")
    @ResponseStatus(HttpStatus.CREATED)
    public StudentDto create(@RequestBody CreateStudentCommand command) {
        if (!PostalCodeValidator.validatePostalCode(command.getAddress().getPostalCode())) {
            throw new InvalidPostalCodeFormatException("Invalid Postal code format");
        }
        if (!PeselValidator.validatePeselNumber(command.getPeselNumber())) {
            throw new InvalidPeselFormatException("Invalid Pesel format");
        }
        return studentService.create(command);
    }

//    @GetMapping("/confirm")
//    public ApiResponse confirmEmail(@RequestParam("token") String token) {
//        boolean isVerified = studentService.confirmEmail(token);
//        if (isVerified) {
//            return new ApiResponse("The student's account has been successfully confirmed.");
//        } else {
//            return new ApiResponse("Invalid confirmation token.");
//        }
//    }

    @GetMapping("/confirm")
    public Object confirmEmail(@RequestParam("token") String token) {
        boolean isVerified = studentService.confirmEmail(token);
        if (isVerified) {
            return new RedirectView("/set-password.html?token=" + token, true, true, false);
        } else {
            return new ApiResponse("Invalid confirmation token.");
        }
    }
    
    @PostMapping("/set-password")
    public ApiResponse setPassword(@RequestParam("token") String token,
                                   @RequestParam("password") String password,
                                   @RequestParam("confirmPassword") String confirmPassword) {
        boolean success = studentService.setPassword(token, password, confirmPassword);
        if (success) {
            return new ApiResponse("Password set successfully");
        } else {
            return new ApiResponse("Failed to set password");
        }
    }
    
    
    @DeleteMapping("/delete")
    @ResponseBody
    public void deleteById(@RequestParam int id) {
        studentService.deleteById(id);
    }
    
    @DeleteMapping("deleteByIndexNumber")
    @ResponseBody
    public void deleteByIndexNumber(@RequestParam int indexNumber) {
        studentService.deleteByIndexNumer(indexNumber);
    }

}

SecurityConfiguration配置

package pl.studia.university.configuration;


@Configuration
@EnableWebSecurity
public class SecurityConfiguration {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
                .csrf(csrf -> csrf.disable())
                .authorizeHttpRequests(auth -> auth

//                        // Allow access to the set-password page and the confirm endpoint without authentication
//                        .requestMatchers("/set-password.html", "/api/v1/students/confirm", "/style.css").permitAll()
//                        // Make sure other endpoints are authenticated properly
//                        .requestMatchers("/api/v1/students").hasRole("ADMIN")
//                        .requestMatchers("/api/v1/students/create").hasAuthority("ROLE_ADMIN")
//                        .requestMatchers("/api/v1/students/delete").hasRole("ADMIN")
//                        .requestMatchers("/api/v1/students/deleteByIndexNumber").hasRole("ADMIN")
//                        .anyRequest().authenticated()
.requestMatchers("/api/v1/students/set-password.html", "/set-password", "/api/v1/students/confirm", "/login.html", "/style.css", "/js/**", "/images/**").permitAll()  
.anyRequest().authenticated()
)
.formLogin(form -> form
.loginPage("/login.html")
.loginProcessingUrl("/perform_login")
.defaultSuccessUrl("/", true)
.failureUrl("/login?error=true")
.permitAll()
)
.logout(logout -> logout
.logoutUrl("/logout")
.logoutSuccessUrl("/login?logout=true")
.permitAll()
)
.httpBasic(withDefaults());

        return http.build();
    }
    
    @Bean
    public UserDetailsService users(PasswordEncoder passwordEncoder) {
        UserDetails admin = User.withUsername("admin")
                .password(passwordEncoder.encode("admin"))
                .roles("ADMIN", "USER")
                .build();
    
        UserDetails user = User.withUsername("user")
                .password(passwordEncoder.encode("user"))
                .roles("USER")
                .build();
    
        return new InMemoryUserDetailsManager(user, admin);
    }
    
    @Bean
    public BCryptPasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

}

我曾尝试将requestMatchers中的路径从/api/v1/students/set-password.html改为/set-password.html,但问题仍未解决。


问题分析与解决方案

一、问题原因

  1. 邮件链接逻辑错误:邮件直接指向设置密码页面,跳过了邮箱验证的前置步骤,且链接路径错误(静态页面不应挂在API路由/api/v1/students下)。
  2. Security配置遗漏:当前配置中,邮件里的错误链接/api/v1/students/set-password.html未被放行,且静态页面/set-password.html的访问规则可能因顺序或匹配问题未生效。
  3. 流程逻辑缺失:直接访问设置密码页面时,没有触发邮箱验证,不符合设计的“验证后设置密码”流程。

二、简易修复方案

1. 修正邮件发送的链接

管理员创建用户后,邮件应发送验证接口链接,而非直接指向设置密码页面:
http://localhost:8080/api/v1/students/confirm?token=d9bbd9b6-d94b-4aac-b4fe-756402eeb6ad

2. 修正Security配置

调整路径匹配顺序,确保所有相关资源都被放行:

.authorizeHttpRequests(auth -> auth
    // 放行静态页面、验证接口、密码设置接口及静态资源
    .requestMatchers("/set-password.html", "/api/v1/students/confirm", "/api/v1/students/set-password", "/login.html", "/style.css", "/js/**", "/images/**").permitAll()
    // 管理员权限接口单独配置
    .requestMatchers("/api/v1/students", "/api/v1/students/create", "/api/v1/students/delete", "/api/v1/students/deleteByIndexNumber").hasRole("ADMIN")
    // 其余接口需认证
    .anyRequest().authenticated()
)

3. 确认静态页面位置

将set-password.html放在src/main/resources/static目录下,确保可以通过/set-password.html直接访问。

三、优化实现方式

1. 完善Token生命周期管理

在Student实体中添加token过期时间字段,避免永久有效的token带来安全风险:

// Student实体新增字段
private LocalDateTime verificationTokenExpiry;

// 验证接口增加过期检查
public boolean confirmEmail(String token) {
    Student student = studentRepository.findByVerificationToken(UUID.fromString(token))
        .orElseThrow(() -> new IllegalArgumentException("无效的验证链接"));
    if (student.getVerificationTokenExpiry().isBefore(LocalDateTime.now())) {
        throw new IllegalArgumentException("验证链接已过期");
    }
    student.setEmailVerified(true);
    studentRepository.save(student);
    return true;
}

2. 优化前端交互逻辑

在set-password.html中使用AJAX提交密码,避免页面跳转,提升用户体验:

// 示例JS代码
document.getElementById('password-form').addEventListener('submit', function(e) {
    e.preventDefault();
    const token = new URLSearchParams(window.location.search).get('token');
    const password = document.getElementById('password').value;
    const confirmPassword = document.getElementById('confirm-password').value;

    fetch('/api/v1/students/set-password', {
        method: 'POST',
        headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
        body: `token=${token}&password=${password}&confirmPassword=${confirmPassword}`
    })
    .then(res => res.json())
    .then(data => {
        alert(data.message);
        if (data.message.includes('successfully')) {
            window.location.href = '/login.html';
        }
    })
    .catch(err => alert('设置密码失败'));
});

3. 全局异常处理

添加全局异常处理器,统一返回友好错误信息:

@RestControllerAdvice
public class GlobalExceptionHandler {
    @ExceptionHandler(IllegalArgumentException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public ApiResponse handleIllegalArgument(IllegalArgumentException e) {
        return new ApiResponse(e.getMessage());
    }

    @ExceptionHandler(IllegalStateException.class)
    @ResponseStatus(HttpStatus.FORBIDDEN)
    public ApiResponse handleIllegalState(IllegalStateException e) {
        return new ApiResponse(e.getMessage());
    }
}

4. 密码设置接口参数优化

改用@RequestBody接收参数,符合REST接口规范:

@PostMapping("/set-password")
public ApiResponse setPassword(@RequestBody SetPasswordRequest request) {
    boolean success = studentService.setPassword(request.getToken(), request.getPassword(), request.getConfirmPassword());
    return success ? new ApiResponse("密码设置成功") : new ApiResponse("密码设置失败");
}

// 新增请求DTO
public class SetPasswordRequest {
    private String token;
    private String password;
    private String confirmPassword;
    // getter和setter
}

内容的提问来源于stack exchange,提问作者sheeshay

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 04:42:03