From 8a92d134b0bdfd9d6890f7345c57aa009e9f5771 Mon Sep 17 00:00:00 2001 From: Gaofeng Date: Wed, 3 Dec 2025 00:33:32 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E5=90=8E=E7=AB=AF=EF=BC=9A=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=82=AE=E4=BB=B6=E9=AA=8C=E8=AF=81=E7=A0=81=E6=89=BE?= =?UTF-8?q?=E5=9B=9E=E5=AF=86=E7=A0=81=E6=B5=81=E7=A8=8B=EF=BC=8C/auths/pa?= =?UTF-8?q?ssword/reset/code=20=E5=8F=91=E9=80=81=E9=87=8D=E7=BD=AE?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=88=E7=8B=AC=E7=AB=8B=E5=AD=98?= =?UTF-8?q?=E5=82=A8/=E9=99=90=E9=A2=91/TTL=EF=BC=89=EF=BC=8C/auths/passwo?= =?UTF-8?q?rd/reset=20=E6=A0=A1=E9=AA=8C=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=E5=90=8E=E6=9B=B4=E6=96=B0=E7=94=A8=E6=88=B7=E5=AF=86=E7=A0=81?= =?UTF-8?q?=EF=BC=9B=E4=BD=BF=E7=94=A8=20EmailVerificationManager=20?= =?UTF-8?q?=E6=96=B0=E5=89=8D=E7=BC=80=EF=BC=9BSMTP=20=E5=8F=91=E9=80=81?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=E9=BB=98=E8=AE=A4=20SMTP=5FSSL=EF=BC=8C?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=20TLS=20=E5=BC=80=E5=85=B3=E3=80=82=202.=20?= =?UTF-8?q?=E5=89=8D=E7=AB=AF=EF=BC=9A=E7=99=BB=E5=BD=95=E9=A1=B5=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E2=80=9C=E5=BF=98=E8=AE=B0=E5=AF=86=E7=A0=81=E2=80=9D?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=EF=BC=8C=E6=94=AF=E6=8C=81=E5=8F=91=E9=80=81?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81=E7=A0=81=E3=80=81=E8=BE=93?= =?UTF-8?q?=E5=85=A5=E9=AA=8C=E8=AF=81=E7=A0=81=E4=B8=8E=E4=B8=A4=E6=AC=A1?= =?UTF-8?q?=E6=96=B0=E5=AF=86=E7=A0=81=E6=A0=A1=E9=AA=8C=EF=BC=8C=E6=8F=90?= =?UTF-8?q?=E4=BA=A4=E9=87=8D=E7=BD=AE=EF=BC=9B=E6=96=B0=20API=20=E5=B0=81?= =?UTF-8?q?=E8=A3=85=20sendResetCode=E3=80=81resetPassword=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/open_webui/main.py | 4 + backend/open_webui/models/auths.py | 10 +++ backend/open_webui/routers/auths.py | 130 ++++++++++++++++++++++++++++ src/lib/apis/auths/index.ts | 58 +++++++++++++ src/routes/auth/+page.svelte | 85 ++++++++++++++++-- 5 files changed, 280 insertions(+), 7 deletions(-) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 95e98d489f..899e018b7d 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -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 diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 0a2f3d0b2d..1bf1baf44d 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -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, diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index 0d87b54010..15bcbf4698 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -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") diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index bd4e0b64b2..5c860c31e5 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -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; diff --git a/src/routes/auth/+page.svelte b/src/routes/auth/+page.svelte index 5bb0796cbc..9538899350 100644 --- a/src/routes/auth/+page.svelte +++ b/src/routes/auth/+page.svelte @@ -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 | 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 /> - {#if mode === 'signup'} + {#if mode === 'signup' || mode === 'reset'}
用户协议 +
{/if} @@ -519,6 +570,24 @@ required /> + {:else if mode === 'reset'} +
+ + +
{/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')} {#if $config?.features.enable_signup && !($config?.onboarding ?? false)}