diff --git a/.gitignore b/.gitignore index 452e9a5..39ba585 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,28 @@ backend/src/main/resources/application-local.yml *.log !backend/.env.example .omx/ +/backend/.env.example +/backend/.mvn-settings-ali.xml +/backend/.mvn-settings-codex.xml +/backend/imeeting-backend.iml +/backend/lombok.config +/database/ +/deploy/ +/docs/ +/rebel.xml +/.editorconfig +/frontend/design/ +/components/ +/bat/ +/.agents/ +/.codex/ +/.gemini/ +/.idea/ +/.m2-temp/ +/.m2-test/ +/.omx/ +/APP_LOG_PATH_IS_UNDEFINED/ +/backend/.m2repo/ +/backend/m2repo_local/ +/backend/src/test/ +/backend/target/ diff --git a/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java b/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java index 6bd52e2..9efd73c 100644 --- a/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java +++ b/backend/src/main/java/com/imeeting/controller/android/AndroidDeviceController.java @@ -54,7 +54,7 @@ public class AndroidDeviceController { } String tenantCode = resolveTenantCode(request, command); AndroidRequestLogHelper.logRequest(log, "Android设备", "设备自注册", "request", command, "tenantCode", tenantCode); - AndroidAuthContext authContext = androidAuthService.authenticateHttp(request, false); + AndroidAuthContext authContext = androidAuthService.authenticateHttpIgnoreToken(request, false); AndroidDeviceRegisterResponse response = androidDeviceRegistrationService.register( tenantCode, authContext.getDeviceId(), diff --git a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java index 31d41e9..9032a88 100644 --- a/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java +++ b/backend/src/main/java/com/imeeting/service/android/AndroidAuthService.java @@ -11,4 +11,6 @@ public interface AndroidAuthService { AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered); AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken); + + AndroidAuthContext authenticateHttpIgnoreToken(HttpServletRequest request, boolean requireRegistered); } diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java index 48e3843..f27ee41 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidAuthServiceImpl.java @@ -68,16 +68,26 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request) { - return authenticateHttp(request, true, false); + return authenticateHttp(request, true, false, false); } @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered) { - return authenticateHttp(request, requireRegistered, false); + return authenticateHttp(request, requireRegistered, false, false); } @Override public AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, boolean allowOptionalToken) { + return authenticateHttp(request, requireRegistered, allowOptionalToken, false); + } + + @Override + public AndroidAuthContext authenticateHttpIgnoreToken(HttpServletRequest request, boolean requireRegistered) { + return authenticateHttp(request, requireRegistered, false, true); + } + + private AndroidAuthContext authenticateHttp(HttpServletRequest request, boolean requireRegistered, + boolean allowOptionalToken, boolean ignoreTokenValidation) { LoginUser loginUser = currentLoginUser(); String resolvedToken = resolveHttpToken(request); String deviceId = firstHeader(request, HEADER_DEVICE_ID); @@ -108,7 +118,7 @@ public class AndroidAuthServiceImpl implements AndroidAuthService { return applyLicenseContext(context, license, allowOptionalToken); } - if (StringUtils.hasText(resolvedToken)) { + if (StringUtils.hasText(resolvedToken) && !ignoreTokenValidation) { InternalAuthCheckResponse authResult = validateToken(resolvedToken); if (requireRegistered && !allowOptionalToken) { androidDeviceBindingService.validatePrivateDeviceAccess(deviceId, authResult.getTenantId(), authResult.getUserId()); diff --git a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java index 0121604..0fcd5b5 100644 --- a/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java +++ b/backend/src/main/java/com/imeeting/service/android/impl/AndroidDeviceRegistrationServiceImpl.java @@ -29,10 +29,10 @@ public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegist @Transactional(rollbackFor = Exception.class) public AndroidDeviceRegisterResponse register(String tenantCode, String deviceCode, String deviceName, String terminalType, String terminalVersion) { if (!StringUtils.hasText(tenantCode)) { - throw new BusinessException("400", "tenantCode不能为空"); + throw new BusinessException("tenantCode不能为空"); } if (!StringUtils.hasText(deviceCode)) { - throw new BusinessException("400", "deviceId不能为空"); + throw new BusinessException("deviceId不能为空"); } SysTenant tenant = requireTenant(tenantCode.trim()); String normalizedDeviceCode = deviceCode.trim(); @@ -81,7 +81,7 @@ public class AndroidDeviceRegistrationServiceImpl implements AndroidDeviceRegist .eq(SysTenant::getIsDeleted, 0) .last("LIMIT 1")); if (tenant == null || tenant.getId() == null) { - throw new BusinessException("400", "租户不存在"); + throw new BusinessException("租户不存在"); } return tenant; } diff --git a/imeeting-h5/src/pages/forgot-password/index.tsx b/imeeting-h5/src/pages/forgot-password/index.tsx new file mode 100644 index 0000000..68fbaab --- /dev/null +++ b/imeeting-h5/src/pages/forgot-password/index.tsx @@ -0,0 +1,241 @@ +import {LockOutlined, MailOutlined, ReloadOutlined, SafetyOutlined, UserOutlined} from "@ant-design/icons"; +import {App, Button, Card, Form, Input, Space, Typography} from "antd"; +import {useEffect, useMemo, useState} from "react"; +import {useNavigate} from "react-router-dom"; + +import { + fetchCaptcha, + fetchPublicPasswordPolicy, + resetPasswordByRecovery, + sendPasswordRecoveryCode, + type PasswordPolicyPublic, +} from "@/api/auth"; +import {usePlatformConfig} from "@/components/PlatformConfigProvider"; +import usePageTitle from "@/hooks/usePageTitle"; +import type {CaptchaResponse} from "@/types"; +import {buildPasswordPolicyValidator, buildPolicyHints} from "@/utils/password"; + +const {Paragraph, Text, Title} = Typography; + +type RecoveryFormValues = { + username: string; + captchaCode?: string; + code: string; + newPassword: string; + confirmPassword: string; +}; + +export default function ForgotPasswordPage() { + const {message} = App.useApp(); + const navigate = useNavigate(); + const {platformConfig, captchaEnabled, loaded} = usePlatformConfig(); + const [form] = Form.useForm(); + const [captcha, setCaptcha] = useState(null); + const [policy, setPolicy] = useState(null); + const [countdown, setCountdown] = useState(0); + const [sending, setSending] = useState(false); + const [submitting, setSubmitting] = useState(false); + + usePageTitle("忘记密码"); + + const policyHints = useMemo(() => buildPolicyHints(policy), [policy]); + + const loadCaptcha = async () => { + try { + if (!captchaEnabled) { + setCaptcha(null); + form.setFieldValue("captchaCode", undefined); + return; + } + + const data = await fetchCaptcha(); + setCaptcha(data); + form.setFieldValue("captchaCode", ""); + } catch { + setCaptcha(null); + } + }; + + useEffect(() => { + if (!loaded) { + return; + } + + const init = async () => { + const policyData = await fetchPublicPasswordPolicy(); + setPolicy(policyData); + await loadCaptcha(); + }; + + void init(); + }, [loaded, captchaEnabled]); + + useEffect(() => { + if (countdown <= 0) { + return; + } + + const timer = window.setTimeout(() => setCountdown((value) => value - 1), 1000); + return () => window.clearTimeout(timer); + }, [countdown]); + + const handleSendCode = async () => { + const fields = captchaEnabled ? ["username", "captchaCode"] : ["username"]; + const values = await form.validateFields(fields); + + if (captchaEnabled && !captcha?.captchaId) { + await loadCaptcha(); + return; + } + + setSending(true); + try { + const resp = await sendPasswordRecoveryCode({ + username: values.username, + captchaId: captchaEnabled ? captcha?.captchaId : undefined, + captchaCode: captchaEnabled ? values.captchaCode : undefined, + channel: "EMAIL", + }); + message.success(resp.msg || "验证码已发送"); + if (resp.data) { + setCountdown(60); + } + form.setFieldValue("captchaCode", ""); + } finally { + if (captchaEnabled) { + await loadCaptcha(); + } + setSending(false); + } + }; + + const handleFinish = async (values: RecoveryFormValues) => { + setSubmitting(true); + try { + await resetPasswordByRecovery({ + username: values.username, + channel: "EMAIL", + code: values.code, + newPassword: values.newPassword, + }); + message.success("密码已重置,请重新登录"); + navigate("/login", {replace: true}); + } finally { + setSubmitting(false); + } + }; + + return ( +
+ + + {platformConfig?.projectName || "iMeeting"} + + 忘记密码 + + + 输入账号并完成邮箱验证后重置密码。 + + + +
+ + } placeholder="用户名或手机号"/> + + + {captchaEnabled ? ( + <> + + } + placeholder="请输入图形验证码" + addonAfter={ + +
+ + + form.getFieldValue("username")), + }, + ]} + extra={ + policyHints.length > 0 ? ( +
{policyHints.slice(0, 3).join(" · ")}
+ ) : null + } + > + } placeholder="请输入新密码" + autoComplete="new-password"/> +
+ + ({ + validator(_, value) { + if (!value || getFieldValue("newPassword") === value) { + return Promise.resolve(); + } + return Promise.reject(new Error("两次输入的新密码不一致")); + }, + }), + ]} + > + } placeholder="请再次输入新密码" + autoComplete="new-password"/> + + + + + + + + + + ); +} diff --git a/imeeting-h5/src/utils/password.ts b/imeeting-h5/src/utils/password.ts new file mode 100644 index 0000000..37ca682 --- /dev/null +++ b/imeeting-h5/src/utils/password.ts @@ -0,0 +1,118 @@ +import type {PasswordPolicyPublic} from "@/api/auth"; + +const DEFAULT_SPECIAL_CHARS = "!@#$%^&*()_+-=[]{}|;:',.<>/?`~\"\\"; +type UsernameResolver = string | undefined | (() => string | undefined); + +export function buildPolicyHints(policy: PasswordPolicyPublic | null): string[] { + if (!policy || policy.enabled === false) { + return ["密码长度至少 6 位"]; + } + + const hints: string[] = [`长度 ${policy.minLength}-${policy.maxLength} 位`]; + if (policy.requireUppercase) hints.push("包含大写字母"); + if (policy.requireLowercase) hints.push("包含小写字母"); + if (policy.requireDigit) hints.push("包含数字"); + if (policy.requireSpecialChar) { + const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS; + hints.push(`包含特殊字符(如 ${chars})`); + } + if (policy.forbidUsernameContain) hints.push("不能包含登录名"); + if (policy.forbidSequentialChars) hints.push("不能包含 3 位及以上连续字符"); + if (policy.forbidRepeatedChars) hints.push("不能包含 3 位及以上重复字符"); + if (policy.customRuleMessage) hints.push(policy.customRuleMessage); + return hints; +} + +function hasSequentialChars(value: string): boolean { + for (let i = 0; i + 2 < value.length; i += 1) { + const a = value.charCodeAt(i); + const b = value.charCodeAt(i + 1); + const c = value.charCodeAt(i + 2); + if (b - a === 1 && c - b === 1) return true; + if (a - b === 1 && b - c === 1) return true; + } + return false; +} + +function hasRepeatedChars(value: string): boolean { + for (let i = 0; i + 2 < value.length; i += 1) { + if (value[i] === value[i + 1] && value[i + 1] === value[i + 2]) { + return true; + } + } + return false; +} + +function resolveUsername(username: UsernameResolver): string | undefined { + return typeof username === "function" ? username() : username; +} + +function containsConfiguredSpecialChar(password: string, specialCharSet: string): boolean { + for (const char of password) { + if (specialCharSet.includes(char)) { + return true; + } + } + return false; +} + +export function validatePasswordAgainstPolicy( + policy: PasswordPolicyPublic | null, + password: string, + username?: string, +): string | null { + if (!password) { + return null; + } + + if (!policy || policy.enabled === false) { + return password.length >= 6 ? null : "密码长度至少 6 位"; + } + + if (password.length < policy.minLength) { + return `密码长度不能少于 ${policy.minLength} 位`; + } + if (password.length > policy.maxLength) { + return `密码长度不能超过 ${policy.maxLength} 位`; + } + if (policy.requireUppercase && !/[A-Z]/.test(password)) { + return "密码需包含大写字母"; + } + if (policy.requireLowercase && !/[a-z]/.test(password)) { + return "密码需包含小写字母"; + } + if (policy.requireDigit && !/[0-9]/.test(password)) { + return "密码需包含数字"; + } + if (policy.requireSpecialChar) { + const chars = policy.specialCharSet?.trim() || DEFAULT_SPECIAL_CHARS; + if (!containsConfiguredSpecialChar(password, chars)) { + return "密码需包含特殊字符"; + } + } + if (policy.forbidUsernameContain && username) { + const name = username.trim().toLowerCase(); + if (name && password.toLowerCase().includes(name)) { + return "密码不能包含登录名"; + } + } + if (policy.forbidSequentialChars && hasSequentialChars(password)) { + return "密码不能包含 3 位及以上连续字符"; + } + if (policy.forbidRepeatedChars && hasRepeatedChars(password)) { + return "密码不能包含 3 位及以上重复字符"; + } + return null; +} + +export function buildPasswordPolicyValidator( + policy: PasswordPolicyPublic | null, + username?: UsernameResolver, +) { + return async (_: unknown, value: string) => { + const error = validatePasswordAgainstPolicy(policy, value, resolveUsername(username)); + if (error) { + throw new Error(error); + } + }; +}