1. 后端:新增邮件验证码找回密码流程,/auths/password/reset/code 发送重置验证码(独立存储/限频/TTL),/auths/password/reset 校验验证码后更新用户密码;使用 EmailVerificationManager 新前缀;SMTP 发送改为默认 SMTP_SSL,移除 TLS 开关。

2. 前端:登录页增加“忘记密码”模式,支持发送邮箱验证码、输入验证码与两次新密码校验,提交重置;新 API 封装 sendResetCode、resetPassword。
This commit is contained in:
Gaofeng 2025-12-03 00:33:32 +08:00
parent e10f795789
commit 8a92d134b0
5 changed files with 280 additions and 7 deletions

View file

@ -581,6 +581,9 @@ async def lifespan(app: FastAPI):
async_mode=True,
)
app.state.email_verification_manager = EmailVerificationManager(app.state.redis)
app.state.reset_verification_manager = EmailVerificationManager(
app.state.redis, prefix="reset:code"
)
if app.state.redis is not None:
app.state.redis_task_command_listener = asyncio.create_task(
@ -644,6 +647,7 @@ app.state.config = AppConfig(
redis_key_prefix=REDIS_KEY_PREFIX,
)
app.state.redis = None
app.state.reset_verification_manager = None
app.state.WEBUI_NAME = WEBUI_NAME
app.state.LICENSE_METADATA = None

View file

@ -94,6 +94,16 @@ class AddUserForm(SignupForm):
role: Optional[str] = "pending"
class ResetPasswordCodeForm(BaseModel):
email: str
class ResetPasswordForm(BaseModel):
email: str
code: str
new_password: str
class AuthsTable:
def insert_new_auth(
self,

View file

@ -11,6 +11,8 @@ from open_webui.models.auths import (
Auths,
Token,
LdapForm,
ResetPasswordCodeForm,
ResetPasswordForm,
SigninForm,
SigninResponse,
SignupForm,
@ -78,6 +80,8 @@ router = APIRouter()
log = logging.getLogger(__name__)
log.setLevel(SRC_LOG_LEVELS["MAIN"])
RESET_CODE_PREFIX = "reset"
############################
# GetSessionUser
############################
@ -794,6 +798,132 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
raise HTTPException(500, detail="An internal error occurred during signup.")
############################
# Password Reset via Email Code
############################
@router.post("/password/reset/code")
async def send_reset_code(request: Request, form_data: ResetPasswordCodeForm):
email = form_data.email.lower()
if not validate_email_format(email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
user = Users.get_user_by_email(email)
# 避免用户枚举:用户不存在也返回成功,不发送邮件
if not user:
return {"status": True}
manager: EmailVerificationManager = getattr(
request.app.state, "reset_verification_manager", None
)
if manager is None:
manager = EmailVerificationManager(request.app.state.redis, prefix="reset:code")
request.app.state.reset_verification_manager = manager
send_interval = request.app.state.email_verification_config["send_interval"]
can_send, remaining = manager.can_send(email, send_interval)
if not can_send:
raise HTTPException(
status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"Please wait {int(remaining)} seconds before requesting a new code.",
)
ttl = request.app.state.email_verification_config["ttl"]
max_attempts = request.app.state.email_verification_config["max_attempts"]
smtp_config = request.app.state.email_verification_config.get("smtp", {})
if not smtp_config.get("server"):
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Email service is not configured.",
)
code = generate_verification_code()
manager.store_code(
email,
code,
ttl,
max_attempts,
request.client.host if request.client else None,
)
try:
send_email(
subject=f"{request.app.state.WEBUI_NAME} Password Reset Code",
body=(
f"Your password reset code is {code}.\n"
f"It expires in {max(1, int(ttl / 60))} minutes."
),
to_email=email,
smtp_server=smtp_config.get("server", ""),
smtp_port=int(smtp_config.get("port", 587)),
smtp_username=smtp_config.get("username", ""),
smtp_password=smtp_config.get("password", ""),
from_email=smtp_config.get("from_email", EMAIL_SMTP_FROM),
)
except Exception as e:
log.error(f"Failed to send password reset email: {e}")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send password reset email.",
)
return {"status": True}
class PasswordResetResponse(BaseModel):
status: bool
@router.post("/password/reset", response_model=PasswordResetResponse)
async def reset_password(request: Request, form_data: ResetPasswordForm):
email = form_data.email.lower()
if not validate_email_format(email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
user = Users.get_user_by_email(email)
if not user:
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_CRED
)
manager: EmailVerificationManager = getattr(
request.app.state, "reset_verification_manager", None
)
if manager is None:
manager = EmailVerificationManager(request.app.state.redis, prefix="reset:code")
request.app.state.reset_verification_manager = manager
if not manager.validate_code(email, form_data.code):
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Invalid or expired verification code.",
)
if len(form_data.new_password.encode("utf-8")) > 72:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail=ERROR_MESSAGES.PASSWORD_TOO_LONG,
)
hashed = get_password_hash(form_data.new_password)
ok = Auths.update_user_password_by_id(user.id, hashed)
if not ok:
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to reset password.",
)
return PasswordResetResponse(status=True)
@router.get("/signout")
async def signout(request: Request, response: Response):
response.delete_cookie("token")

View file

@ -353,6 +353,64 @@ export const sendSignupCode = async (email: string) => {
return res;
};
export const sendResetCode = async (email: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/password/reset/code`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email })
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail ?? err.message ?? err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const resetPassword = async (email: string, code: string, newPassword: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/password/reset`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email,
code,
new_password: newPassword
})
})
.then(async (res) => {
if (!res.ok) throw await res.json();
return res.json();
})
.catch((err) => {
console.error(err);
error = err.detail ?? err.message ?? err;
return null;
});
if (error) {
throw error;
}
return res;
};
export const userSignOut = async () => {
let error = null;

View file

@ -14,7 +14,9 @@
getSessionUser,
userSignIn,
userSignUp,
sendSignupCode
sendSignupCode,
sendResetCode,
resetPassword
} from '$lib/apis/auths';
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
@ -46,6 +48,16 @@
let sendCodeTimer: ReturnType<typeof setInterval> | null = null;
let sendingCode = false;
const switchMode = (target: string) => {
clearSendCodeTimer();
mode = target;
verificationCode = '';
password = '';
confirmPassword = '';
sendCodeCooldown = 0;
sendingCode = false;
};
let ldapUsername = '';
let agreeToTerms = false;
let showAgreementModal = false;
@ -110,6 +122,28 @@
await setSessionUser(sessionUser);
};
const resetPasswordHandler = async () => {
if (!verificationCode) {
toast.error('请输入邮箱验证码');
return;
}
if (password !== confirmPassword) {
toast.error($i18n.t('Passwords do not match.'));
return;
}
const res = await resetPassword(email, verificationCode, password).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success('密码已重置,请使用新密码登录');
switchMode('signin');
}
};
const ldapSignInHandler = async () => {
const sessionUser = await ldapUserSignIn(ldapUsername, password).catch((error) => {
toast.error(`${error}`);
@ -150,10 +184,16 @@
}
sendingCode = true;
const res = await sendSignupCode(email).catch((error) => {
toast.error(`${error}`);
return null;
});
const res =
mode === 'reset'
? await sendResetCode(email).catch((error) => {
toast.error(`${error}`);
return null;
})
: await sendSignupCode(email).catch((error) => {
toast.error(`${error}`);
return null;
});
sendingCode = false;
if (res) {
@ -172,6 +212,8 @@
return;
}
await signInHandler();
} else if (mode === 'reset') {
await resetPasswordHandler();
} else {
if (!agreeToPrivacy) {
toast.error('如果要注册,请先同意隐私协议');
@ -332,6 +374,8 @@
{$i18n.t(`Get started with {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
{:else if mode === 'ldap'}
{$i18n.t(`Sign in to {{WEBUI_NAME}} with LDAP`, { WEBUI_NAME: $WEBUI_NAME })}
{:else if mode === 'reset'}
重置密码
{:else if mode === 'signin'}
{$i18n.t(`Sign in to {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
{:else}
@ -400,7 +444,7 @@
required
/>
</div>
{#if mode === 'signup'}
{#if mode === 'signup' || mode === 'reset'}
<div class="mb-2">
<label for="verification-code" class="text-sm font-medium text-left mb-1 block"
>邮箱验证码</label
@ -498,6 +542,13 @@
>
用户协议
</button>
<button
type="button"
class="ml-auto underline font-medium hover:text-gray-900 dark:hover:text-gray-100"
on:click={() => switchMode('reset')}
>
忘记密码?
</button>
</div>
{/if}
@ -519,6 +570,24 @@
required
/>
</div>
{:else if mode === 'reset'}
<div class="mt-2">
<label
for="confirm-password"
class="text-sm font-medium text-left mb-1 block"
>{$i18n.t('Confirm Password')}</label
>
<SensitiveInput
bind:value={confirmPassword}
type="password"
id="confirm-password"
class="my-0.5 w-full text-sm outline-hidden bg-transparent"
placeholder={$i18n.t('Confirm Your Password')}
autocomplete="new-password"
name="confirm-password"
required
/>
</div>
{/if}
{#if mode === 'signup'}
@ -592,7 +661,9 @@
? $i18n.t('Sign in')
: ($config?.onboarding ?? false)
? $i18n.t('Create Admin Account')
: $i18n.t('Create Account')}
: mode === 'reset'
? '重置密码'
: $i18n.t('Create Account')}
</button>
{#if $config?.features.enable_signup && !($config?.onboarding ?? false)}