mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-12 20:35:19 +00:00
1. 后端:新增邮件验证码找回密码流程,/auths/password/reset/code 发送重置验证码(独立存储/限频/TTL),/auths/password/reset 校验验证码后更新用户密码;使用 EmailVerificationManager 新前缀;SMTP 发送改为默认 SMTP_SSL,移除 TLS 开关。
2. 前端:登录页增加“忘记密码”模式,支持发送邮箱验证码、输入验证码与两次新密码校验,提交重置;新 API 封装 sendResetCode、resetPassword。
This commit is contained in:
parent
e10f795789
commit
8a92d134b0
5 changed files with 280 additions and 7 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Reference in a new issue