mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-15 05:45: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,
|
async_mode=True,
|
||||||
)
|
)
|
||||||
app.state.email_verification_manager = EmailVerificationManager(app.state.redis)
|
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:
|
if app.state.redis is not None:
|
||||||
app.state.redis_task_command_listener = asyncio.create_task(
|
app.state.redis_task_command_listener = asyncio.create_task(
|
||||||
|
|
@ -644,6 +647,7 @@ app.state.config = AppConfig(
|
||||||
redis_key_prefix=REDIS_KEY_PREFIX,
|
redis_key_prefix=REDIS_KEY_PREFIX,
|
||||||
)
|
)
|
||||||
app.state.redis = None
|
app.state.redis = None
|
||||||
|
app.state.reset_verification_manager = None
|
||||||
|
|
||||||
app.state.WEBUI_NAME = WEBUI_NAME
|
app.state.WEBUI_NAME = WEBUI_NAME
|
||||||
app.state.LICENSE_METADATA = None
|
app.state.LICENSE_METADATA = None
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,16 @@ class AddUserForm(SignupForm):
|
||||||
role: Optional[str] = "pending"
|
role: Optional[str] = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordCodeForm(BaseModel):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class ResetPasswordForm(BaseModel):
|
||||||
|
email: str
|
||||||
|
code: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
class AuthsTable:
|
class AuthsTable:
|
||||||
def insert_new_auth(
|
def insert_new_auth(
|
||||||
self,
|
self,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,8 @@ from open_webui.models.auths import (
|
||||||
Auths,
|
Auths,
|
||||||
Token,
|
Token,
|
||||||
LdapForm,
|
LdapForm,
|
||||||
|
ResetPasswordCodeForm,
|
||||||
|
ResetPasswordForm,
|
||||||
SigninForm,
|
SigninForm,
|
||||||
SigninResponse,
|
SigninResponse,
|
||||||
SignupForm,
|
SignupForm,
|
||||||
|
|
@ -78,6 +80,8 @@ router = APIRouter()
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
log.setLevel(SRC_LOG_LEVELS["MAIN"])
|
||||||
|
|
||||||
|
RESET_CODE_PREFIX = "reset"
|
||||||
|
|
||||||
############################
|
############################
|
||||||
# GetSessionUser
|
# 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.")
|
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")
|
@router.get("/signout")
|
||||||
async def signout(request: Request, response: Response):
|
async def signout(request: Request, response: Response):
|
||||||
response.delete_cookie("token")
|
response.delete_cookie("token")
|
||||||
|
|
|
||||||
|
|
@ -353,6 +353,64 @@ export const sendSignupCode = async (email: string) => {
|
||||||
return res;
|
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 () => {
|
export const userSignOut = async () => {
|
||||||
let error = null;
|
let error = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
getSessionUser,
|
getSessionUser,
|
||||||
userSignIn,
|
userSignIn,
|
||||||
userSignUp,
|
userSignUp,
|
||||||
sendSignupCode
|
sendSignupCode,
|
||||||
|
sendResetCode,
|
||||||
|
resetPassword
|
||||||
} from '$lib/apis/auths';
|
} from '$lib/apis/auths';
|
||||||
|
|
||||||
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
|
||||||
|
|
@ -46,6 +48,16 @@
|
||||||
let sendCodeTimer: ReturnType<typeof setInterval> | null = null;
|
let sendCodeTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
let sendingCode = false;
|
let sendingCode = false;
|
||||||
|
|
||||||
|
const switchMode = (target: string) => {
|
||||||
|
clearSendCodeTimer();
|
||||||
|
mode = target;
|
||||||
|
verificationCode = '';
|
||||||
|
password = '';
|
||||||
|
confirmPassword = '';
|
||||||
|
sendCodeCooldown = 0;
|
||||||
|
sendingCode = false;
|
||||||
|
};
|
||||||
|
|
||||||
let ldapUsername = '';
|
let ldapUsername = '';
|
||||||
let agreeToTerms = false;
|
let agreeToTerms = false;
|
||||||
let showAgreementModal = false;
|
let showAgreementModal = false;
|
||||||
|
|
@ -110,6 +122,28 @@
|
||||||
await setSessionUser(sessionUser);
|
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 ldapSignInHandler = async () => {
|
||||||
const sessionUser = await ldapUserSignIn(ldapUsername, password).catch((error) => {
|
const sessionUser = await ldapUserSignIn(ldapUsername, password).catch((error) => {
|
||||||
toast.error(`${error}`);
|
toast.error(`${error}`);
|
||||||
|
|
@ -150,10 +184,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
sendingCode = true;
|
sendingCode = true;
|
||||||
const res = await sendSignupCode(email).catch((error) => {
|
const res =
|
||||||
toast.error(`${error}`);
|
mode === 'reset'
|
||||||
return null;
|
? await sendResetCode(email).catch((error) => {
|
||||||
});
|
toast.error(`${error}`);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
: await sendSignupCode(email).catch((error) => {
|
||||||
|
toast.error(`${error}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
sendingCode = false;
|
sendingCode = false;
|
||||||
|
|
||||||
if (res) {
|
if (res) {
|
||||||
|
|
@ -172,6 +212,8 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await signInHandler();
|
await signInHandler();
|
||||||
|
} else if (mode === 'reset') {
|
||||||
|
await resetPasswordHandler();
|
||||||
} else {
|
} else {
|
||||||
if (!agreeToPrivacy) {
|
if (!agreeToPrivacy) {
|
||||||
toast.error('如果要注册,请先同意隐私协议');
|
toast.error('如果要注册,请先同意隐私协议');
|
||||||
|
|
@ -332,6 +374,8 @@
|
||||||
{$i18n.t(`Get started with {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
|
{$i18n.t(`Get started with {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
|
||||||
{:else if mode === 'ldap'}
|
{:else if mode === 'ldap'}
|
||||||
{$i18n.t(`Sign in to {{WEBUI_NAME}} with LDAP`, { WEBUI_NAME: $WEBUI_NAME })}
|
{$i18n.t(`Sign in to {{WEBUI_NAME}} with LDAP`, { WEBUI_NAME: $WEBUI_NAME })}
|
||||||
|
{:else if mode === 'reset'}
|
||||||
|
重置密码
|
||||||
{:else if mode === 'signin'}
|
{:else if mode === 'signin'}
|
||||||
{$i18n.t(`Sign in to {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
|
{$i18n.t(`Sign in to {{WEBUI_NAME}}`, { WEBUI_NAME: $WEBUI_NAME })}
|
||||||
{:else}
|
{:else}
|
||||||
|
|
@ -400,7 +444,7 @@
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{#if mode === 'signup'}
|
{#if mode === 'signup' || mode === 'reset'}
|
||||||
<div class="mb-2">
|
<div class="mb-2">
|
||||||
<label for="verification-code" class="text-sm font-medium text-left mb-1 block"
|
<label for="verification-code" class="text-sm font-medium text-left mb-1 block"
|
||||||
>邮箱验证码</label
|
>邮箱验证码</label
|
||||||
|
|
@ -498,6 +542,13 @@
|
||||||
>
|
>
|
||||||
用户协议
|
用户协议
|
||||||
</button>
|
</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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|
@ -519,6 +570,24 @@
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
||||||
|
|
||||||
{#if mode === 'signup'}
|
{#if mode === 'signup'}
|
||||||
|
|
@ -592,7 +661,9 @@
|
||||||
? $i18n.t('Sign in')
|
? $i18n.t('Sign in')
|
||||||
: ($config?.onboarding ?? false)
|
: ($config?.onboarding ?? false)
|
||||||
? $i18n.t('Create Admin Account')
|
? $i18n.t('Create Admin Account')
|
||||||
: $i18n.t('Create Account')}
|
: mode === 'reset'
|
||||||
|
? '重置密码'
|
||||||
|
: $i18n.t('Create Account')}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{#if $config?.features.enable_signup && !($config?.onboarding ?? false)}
|
{#if $config?.features.enable_signup && !($config?.onboarding ?? false)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue