| | |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.time.LocalDateTime; |
| | | import java.util.UUID; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.regex.Pattern; |
| | | |
| | | /** |
| | | * APP 登录认证服务,负责短信登录、密码登录、微信登录与忘记密码流程。 |
| | | * APP 登录认证服务,负责短信登录、第三方登录与登录态刷新流程。 |
| | | */ |
| | | @Service |
| | | public class AppAuthService { |
| | | |
| | | private static final Pattern PHONE_PATTERN = Pattern.compile("^1\\d{10}$"); |
| | | private static final Pattern PASSWORD_PATTERN = Pattern.compile("^(?![0-9]+$)(?![a-zA-Z]+$)(?![^0-9a-zA-Z]+$).{8,32}$"); |
| | | private static final String PASSWORD_RESET_TICKET_CACHE_PREFIX = "app:password-reset:ticket:"; |
| | | private static final long PASSWORD_RESET_TICKET_EXPIRE_MINUTES = 10L; |
| | | private static final String PASSWORD_AUTH_DISABLED_MESSAGE = "当前版本仅支持验证码登录"; |
| | | private final UserMapper userMapper; |
| | | private final AppSmsCodeService appSmsCodeService; |
| | | private final BCryptPasswordEncoder passwordEncoder; |
| | |
| | | } |
| | | |
| | | /** |
| | | * 为已注册 APP 用户生成忘记密码验证码。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppSmsCodeSendResponse sendPasswordResetCode(AppSmsCodeSendRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | return appSmsCodeService.createPasswordResetCode(phone); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 使用手机号密码完成 APP 用户登录。 |
| | | * 当前版本已下线密码登录,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppLoginResponse passwordLogin(AppPasswordLoginRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | String password = normalizeLoginPassword(request.getPassword()); |
| | | AppUser user = requireEnabledUserByPhone(phone); |
| | | if (!passwordEncoder.matches(password, user.getPassword())) { |
| | | throw new IllegalArgumentException("账号或密码错误"); |
| | | } |
| | | return createLoginResponse(user); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 校验忘记密码验证码并生成一次性重置凭证。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public AppPasswordResetVerifyResponse verifyPasswordResetCode(AppPasswordResetVerifyRequest request) { |
| | | String phone = normalizePhone(request.getPhone()); |
| | | requireEnabledUserByPhone(phone); |
| | | appSmsCodeService.validatePasswordResetCode(phone, normalizeCode(request.getCode())); |
| | | |
| | | String resetTicket = createResetTicket(); |
| | | cacheResetTicket(resetTicket, phone); |
| | | return new AppPasswordResetVerifyResponse(resetTicket); |
| | | throwPasswordAuthDisabled(); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 使用一次性重置凭证更新 APP 用户密码。 |
| | | * 当前版本已下线密码能力,旧客户端调用时返回统一提示。 |
| | | */ |
| | | public void resetPassword(AppPasswordResetRequest request) { |
| | | String resetTicket = normalizeResetTicket(request.getResetTicket()); |
| | | String password = normalizeNewPassword(request.getPassword()); |
| | | String confirmPassword = normalizeConfirmPassword(request.getConfirmPassword()); |
| | | validatePasswordConfirmation(password, confirmPassword); |
| | | |
| | | String phone = loadPhoneByResetTicket(resetTicket); |
| | | AppUser user = requireEnabledUserByPhone(phone); |
| | | user.setPassword(passwordEncoder.encode(password)); |
| | | userMapper.updateById(user); |
| | | stringRedisTemplate.delete(buildResetTicketKey(resetTicket)); |
| | | throwPasswordAuthDisabled(); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | |
| | | /** |
| | | * 归一化密码登录场景下的密码输入,仅要求非空。 |
| | | * 抛出 APP 密码能力下线后的统一业务提示。 |
| | | */ |
| | | private String normalizeLoginPassword(String password) { |
| | | String normalized = trimToNull(password); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入密码"); |
| | | } |
| | | return normalized; |
| | | private void throwPasswordAuthDisabled() { |
| | | throw new IllegalArgumentException(PASSWORD_AUTH_DISABLED_MESSAGE); |
| | | } |
| | | |
| | | /** |
| | |
| | | } |
| | | String upperInviteCode = normalized.toUpperCase(); |
| | | return upperInviteCode.matches("^[A-Z0-9]{6}$") ? upperInviteCode : null; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验忘记密码场景下的新密码格式。 |
| | | */ |
| | | private String normalizeNewPassword(String password) { |
| | | String normalized = trimToNull(password); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请输入新密码"); |
| | | } |
| | | if (!PASSWORD_PATTERN.matcher(normalized).matches()) { |
| | | throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验确认密码输入。 |
| | | */ |
| | | private String normalizeConfirmPassword(String confirmPassword) { |
| | | String normalized = trimToNull(confirmPassword); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("请再次输入密码"); |
| | | } |
| | | if (!PASSWORD_PATTERN.matcher(normalized).matches()) { |
| | | throw new IllegalArgumentException("密码需为8-32位,且包含数字、字母、符号至少2种"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 归一化并校验一次性重置凭证。 |
| | | */ |
| | | private String normalizeResetTicket(String resetTicket) { |
| | | String normalized = trimToNull(resetTicket); |
| | | if (normalized == null) { |
| | | throw new IllegalArgumentException("重置凭证已失效,请重新验证"); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 校验两次输入的新密码保持一致。 |
| | | */ |
| | | private void validatePasswordConfirmation(String password, String confirmPassword) { |
| | | if (!password.equals(confirmPassword)) { |
| | | throw new IllegalArgumentException("两次密码输入不一致"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 生成忘记密码场景使用的一次性重置凭证。 |
| | | */ |
| | | private String createResetTicket() { |
| | | return UUID.randomUUID().toString().replace("-", ""); |
| | | } |
| | | |
| | | /** |
| | | * 将重置凭证与手机号绑定写入 Redis,供下一步更新密码使用。 |
| | | */ |
| | | private void cacheResetTicket(String resetTicket, String phone) { |
| | | stringRedisTemplate.opsForValue().set( |
| | | buildResetTicketKey(resetTicket), |
| | | phone, |
| | | PASSWORD_RESET_TICKET_EXPIRE_MINUTES, |
| | | TimeUnit.MINUTES |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * 从 Redis 读取并校验一次性重置凭证对应的手机号。 |
| | | */ |
| | | private String loadPhoneByResetTicket(String resetTicket) { |
| | | String phone = stringRedisTemplate.opsForValue().get(buildResetTicketKey(resetTicket)); |
| | | if (phone == null || phone.trim().isEmpty()) { |
| | | throw new IllegalArgumentException("重置凭证已失效,请重新验证"); |
| | | } |
| | | return phone; |
| | | } |
| | | |
| | | /** |
| | | * 构造忘记密码重置凭证的 Redis key。 |
| | | */ |
| | | private String buildResetTicketKey(String resetTicket) { |
| | | return PASSWORD_RESET_TICKET_CACHE_PREFIX + resetTicket; |
| | | } |
| | | |
| | | /** |