feat: 添加密码找回页面和更新认证逻辑
- 新增 `forgot-password/index.tsx` 页面,实现密码找回功能 - 更新 `AndroidAuthServiceImpl`,添加 `authenticateHttpIgnoreToken` 方法,并在 `authenticateHttp` 方法中增加 `ignoreTokenValidation` 参数 - 更新 `AndroidAuthService` 接口,添加 `authenticateHttpIgnoreToken` 方法 - 更新 `AndroidDeviceController`,使用 `authenticateHttpIgnoreToken` 方法进行认证 - 优化 `AndroidDeviceRegistrationServiceImpl` 中的异常信息 - 更新 `.gitignore`,忽略不必要的文件和目录dev_na
parent
ee1e75eda2
commit
194a05cbe0
|
|
@ -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/
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue