feat: 添加密码找回页面和更新认证逻辑

- 新增 `forgot-password/index.tsx` 页面,实现密码找回功能
- 更新 `AndroidAuthServiceImpl`,添加 `authenticateHttpIgnoreToken` 方法,并在 `authenticateHttp` 方法中增加 `ignoreTokenValidation` 参数
- 更新 `AndroidAuthService` 接口,添加 `authenticateHttpIgnoreToken` 方法
- 更新 `AndroidDeviceController`,使用 `authenticateHttpIgnoreToken` 方法进行认证
- 优化 `AndroidDeviceRegistrationServiceImpl` 中的异常信息
- 更新 `.gitignore`,忽略不必要的文件和目录
dev_na
chenhao 2026-06-18 14:52:13 +08:00
parent ee1e75eda2
commit 194a05cbe0
7 changed files with 403 additions and 7 deletions

25
.gitignore vendored
View File

@ -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/

View File

@ -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(),

View File

@ -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);
}

View File

@ -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());

View File

@ -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;
}

View File

@ -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<RecoveryFormValues>();
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null);
const [policy, setPolicy] = useState<PasswordPolicyPublic | null>(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 (
<div
className="login-page login-page--compact"
style={
platformConfig?.loginBgUrl
? {
backgroundImage: `linear-gradient(rgba(251,253,255,0.9), rgba(242,246,251,0.96)), url(${platformConfig.loginBgUrl})`,
backgroundSize: "cover",
backgroundPosition: "center",
}
: undefined
}
>
<Card className="surface-card login-card login-card--compact">
<Space direction="vertical" size={8} style={{width: "100%", marginBottom: 18}}>
<Text className="login-page__badge">{platformConfig?.projectName || "iMeeting"}</Text>
<Title level={3} style={{margin: 0}}>
</Title>
<Paragraph type="secondary" style={{marginBottom: 0}}>
</Paragraph>
</Space>
<Form form={form} layout="vertical" onFinish={handleFinish} requiredMark={false}>
<Form.Item name="username" label="账号" rules={[{required: true, message: "请输入账号"}]}>
<Input size="large" prefix={<UserOutlined/>} placeholder="用户名或手机号"/>
</Form.Item>
{captchaEnabled ? (
<>
<Form.Item name="captchaCode" label="图形验证码" rules={[{required: true, message: "请输入图形验证码"}]}>
<Input
size="large"
prefix={<SafetyOutlined/>}
placeholder="请输入图形验证码"
addonAfter={
<Button type="text" icon={<ReloadOutlined/>} onClick={() => void loadCaptcha()}
aria-label="刷新验证码"/>
}
/>
</Form.Item>
{captcha?.imageBase64 ? (
<div className="captcha-preview captcha-preview--compact">
<img src={captcha.imageBase64} alt="验证码"/>
</div>
) : null}
</>
) : null}
<Form.Item label="邮箱验证码" required>
<div className="login-inline-field">
<Form.Item name="code" noStyle rules={[{required: true, message: "请输入邮箱验证码"}]}>
<Input size="large" prefix={<MailOutlined/>} placeholder="邮箱验证码" maxLength={6}/>
</Form.Item>
<Button size="large" onClick={() => void handleSendCode()} disabled={countdown > 0} loading={sending}>
{countdown > 0 ? `${countdown}s` : "发送"}
</Button>
</div>
</Form.Item>
<Form.Item
name="newPassword"
label="新密码"
validateFirst
rules={[
{required: true, message: "请输入新密码"},
{
validator: buildPasswordPolicyValidator(policy, () => form.getFieldValue("username")),
},
]}
extra={
policyHints.length > 0 ? (
<div className="login-form__hint-list">{policyHints.slice(0, 3).join(" · ")}</div>
) : null
}
>
<Input.Password size="large" prefix={<LockOutlined/>} placeholder="请输入新密码"
autoComplete="new-password"/>
</Form.Item>
<Form.Item
name="confirmPassword"
label="确认新密码"
dependencies={["newPassword"]}
rules={[
{required: true, message: "请再次输入新密码"},
({getFieldValue}) => ({
validator(_, value) {
if (!value || getFieldValue("newPassword") === value) {
return Promise.resolve();
}
return Promise.reject(new Error("两次输入的新密码不一致"));
},
}),
]}
>
<Input.Password size="large" prefix={<LockOutlined/>} placeholder="请再次输入新密码"
autoComplete="new-password"/>
</Form.Item>
<Space direction="vertical" size={10} style={{width: "100%"}}>
<Button type="primary" htmlType="submit" size="large" block loading={submitting}>
</Button>
<Button size="large" block onClick={() => navigate("/login")}>
</Button>
</Space>
</Form>
</Card>
</div>
);
}

View File

@ -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);
}
};
}