- 注册强制邮箱验证码:后端新增验证码发送/校验链路,/api/auths/signup/code 发送验证码,/api/auths/signup 必填 code 校验后才创建用户(首个用户亦需验证码)。

- 邮件发送改为固定 SMTPS(SSL):新增 backend/open_webui/utils/email_utils.py,使用 SMTP_SSL 直连;去掉 TLS 开关,SMTP 配置只含 server/port/username/password/from。
- 配置改动:SMTP 相关环境变量仅需设定服务器、端口、账户、密码、发信人。
- 前端注册页:验证码输入与发送按钮始终展示;验证码必填;新增 sendSignupCode API 调用;userSignUp 必传验证码。
This commit is contained in:
Gaofeng 2025-12-02 23:31:31 +08:00
parent 29d2b0792a
commit e10f795789
8 changed files with 365 additions and 29 deletions

View file

@ -417,6 +417,59 @@ ENABLE_SIGNUP_PASSWORD_CONFIRMATION = (
os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true"
) )
# 注册邮箱验证码相关配置(强制开启,不再提供开关)
# - EMAIL_SMTP_SERVERSMTP 服务器地址,必填
# - EMAIL_SMTP_PORTSMTP 端口(默认 587
# - EMAIL_SMTP_USERNAMESMTP 登录用户名
# - EMAIL_SMTP_PASSWORDSMTP 登录密码
# - EMAIL_SMTP_FROM发信人地址默认 no-reply@localhost
# - EMAIL_VERIFICATION_CODE_TTL验证码有效期秒数默认 600
# - EMAIL_VERIFICATION_SEND_INTERVAL同邮箱再次发送的冷却秒数默认 60
# - EMAIL_VERIFICATION_MAX_ATTEMPTS验证码最大验证次数超限需重发默认 5
ENABLE_SIGNUP_EMAIL_VERIFICATION = True
# SMTP 配置:用于发送注册验证码
# 服务器地址(开启验证码时必填)
EMAIL_SMTP_SERVER = os.environ.get("EMAIL_SMTP_SERVER", "")
# 端口,默认 587
EMAIL_SMTP_PORT = os.environ.get("EMAIL_SMTP_PORT", "587")
try:
EMAIL_SMTP_PORT = int(EMAIL_SMTP_PORT)
except ValueError:
EMAIL_SMTP_PORT = 587
EMAIL_SMTP_USERNAME = os.environ.get("EMAIL_SMTP_USERNAME", "")
EMAIL_SMTP_PASSWORD = os.environ.get("EMAIL_SMTP_PASSWORD", "")
# 发信人地址
EMAIL_SMTP_FROM = os.environ.get("EMAIL_SMTP_FROM", "no-reply@localhost")
# 验证码策略:有效期、发送冷却、最大尝试次数
# 有效期(秒)
EMAIL_VERIFICATION_CODE_TTL = os.environ.get("EMAIL_VERIFICATION_CODE_TTL", "600")
try:
EMAIL_VERIFICATION_CODE_TTL = int(EMAIL_VERIFICATION_CODE_TTL)
except ValueError:
EMAIL_VERIFICATION_CODE_TTL = 600
# 发送冷却(秒)
EMAIL_VERIFICATION_SEND_INTERVAL = os.environ.get(
"EMAIL_VERIFICATION_SEND_INTERVAL", "60"
)
try:
EMAIL_VERIFICATION_SEND_INTERVAL = int(EMAIL_VERIFICATION_SEND_INTERVAL)
except ValueError:
EMAIL_VERIFICATION_SEND_INTERVAL = 60
# 最大验证尝试次数
EMAIL_VERIFICATION_MAX_ATTEMPTS = os.environ.get(
"EMAIL_VERIFICATION_MAX_ATTEMPTS", "5"
)
try:
EMAIL_VERIFICATION_MAX_ATTEMPTS = int(EMAIL_VERIFICATION_MAX_ATTEMPTS)
except ValueError:
EMAIL_VERIFICATION_MAX_ATTEMPTS = 5
WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get(
"WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None
) )

View file

@ -441,6 +441,14 @@ from open_webui.env import (
WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SAME_SITE,
WEBUI_SESSION_COOKIE_SECURE, WEBUI_SESSION_COOKIE_SECURE,
ENABLE_SIGNUP_PASSWORD_CONFIRMATION, ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
EMAIL_SMTP_SERVER,
EMAIL_SMTP_PORT,
EMAIL_SMTP_USERNAME,
EMAIL_SMTP_PASSWORD,
EMAIL_SMTP_FROM,
EMAIL_VERIFICATION_CODE_TTL,
EMAIL_VERIFICATION_SEND_INTERVAL,
EMAIL_VERIFICATION_MAX_ATTEMPTS,
WEBUI_AUTH_TRUSTED_EMAIL_HEADER, WEBUI_AUTH_TRUSTED_EMAIL_HEADER,
WEBUI_AUTH_TRUSTED_NAME_HEADER, WEBUI_AUTH_TRUSTED_NAME_HEADER,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL, WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
@ -465,6 +473,7 @@ from open_webui.utils.models import (
check_model_access, check_model_access,
get_filtered_models, get_filtered_models,
) )
from open_webui.utils.email_utils import EmailVerificationManager
from open_webui.utils.chat import ( from open_webui.utils.chat import (
generate_chat_completion as chat_completion_handler, generate_chat_completion as chat_completion_handler,
chat_completed as chat_completed_handler, chat_completed as chat_completed_handler,
@ -571,6 +580,7 @@ async def lifespan(app: FastAPI):
redis_cluster=REDIS_CLUSTER, redis_cluster=REDIS_CLUSTER,
async_mode=True, async_mode=True,
) )
app.state.email_verification_manager = EmailVerificationManager(app.state.redis)
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(
@ -733,6 +743,20 @@ app.state.config.JWT_EXPIRES_IN = JWT_EXPIRES_IN
app.state.config.SHOW_ADMIN_DETAILS = SHOW_ADMIN_DETAILS app.state.config.SHOW_ADMIN_DETAILS = SHOW_ADMIN_DETAILS
app.state.config.ADMIN_EMAIL = ADMIN_EMAIL app.state.config.ADMIN_EMAIL = ADMIN_EMAIL
app.state.email_verification_enabled = True
app.state.email_verification_config = {
"ttl": EMAIL_VERIFICATION_CODE_TTL,
"send_interval": EMAIL_VERIFICATION_SEND_INTERVAL,
"max_attempts": EMAIL_VERIFICATION_MAX_ATTEMPTS,
"smtp": {
"server": EMAIL_SMTP_SERVER,
"port": EMAIL_SMTP_PORT,
"username": EMAIL_SMTP_USERNAME,
"password": EMAIL_SMTP_PASSWORD,
"from_email": EMAIL_SMTP_FROM,
},
}
app.state.config.DEFAULT_MODELS = DEFAULT_MODELS app.state.config.DEFAULT_MODELS = DEFAULT_MODELS
app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS app.state.config.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS
@ -1870,26 +1894,28 @@ async def get_app_config(request: Request):
return { return {
**({"onboarding": True} if onboarding else {}), **({"onboarding": True} if onboarding else {}),
"status": True, "status": True,
"name": app.state.WEBUI_NAME, "name": app.state.WEBUI_NAME,
"version": VERSION, "version": VERSION,
"default_locale": str(DEFAULT_LOCALE), "default_locale": str(DEFAULT_LOCALE),
"oauth": { "oauth": {
"providers": { "providers": {
name: config.get("name", name) name: config.get("name", name)
for name, config in OAUTH_PROVIDERS.items() for name, config in OAUTH_PROVIDERS.items()
} }
}, },
"features": { "features": {
"auth": WEBUI_AUTH, "auth": WEBUI_AUTH,
"auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER),
"enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION,
"enable_ldap": app.state.config.ENABLE_LDAP, "enable_signup_email_verification": True,
"enable_api_key": app.state.config.ENABLE_API_KEY, "enable_ldap": app.state.config.ENABLE_LDAP,
"enable_signup": app.state.config.ENABLE_SIGNUP, "enable_api_key": app.state.config.ENABLE_API_KEY,
"enable_login_form": app.state.config.ENABLE_LOGIN_FORM, "enable_signup": app.state.config.ENABLE_SIGNUP,
"enable_websocket": ENABLE_WEBSOCKET_SUPPORT, "enable_login_form": app.state.config.ENABLE_LOGIN_FORM,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK, "enable_websocket": ENABLE_WEBSOCKET_SUPPORT,
"enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK,
"signup_email_verification_send_interval": app.state.email_verification_config["send_interval"],
**( **(
{ {
"enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS, "enable_direct_connections": app.state.config.ENABLE_DIRECT_CONNECTIONS,

View file

@ -83,6 +83,11 @@ class SignupForm(BaseModel):
email: str email: str
password: str password: str
profile_image_url: Optional[str] = "/user.png" profile_image_url: Optional[str] = "/user.png"
code: str
class SignupCodeForm(BaseModel):
email: str
class AddUserForm(SignupForm): class AddUserForm(SignupForm):

View file

@ -14,6 +14,7 @@ from open_webui.models.auths import (
SigninForm, SigninForm,
SigninResponse, SigninResponse,
SignupForm, SignupForm,
SignupCodeForm,
UpdatePasswordForm, UpdatePasswordForm,
UserResponse, UserResponse,
) )
@ -30,6 +31,14 @@ from open_webui.env import (
WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SAME_SITE,
WEBUI_AUTH_COOKIE_SECURE, WEBUI_AUTH_COOKIE_SECURE,
WEBUI_AUTH_SIGNOUT_REDIRECT_URL, WEBUI_AUTH_SIGNOUT_REDIRECT_URL,
EMAIL_VERIFICATION_CODE_TTL,
EMAIL_VERIFICATION_SEND_INTERVAL,
EMAIL_VERIFICATION_MAX_ATTEMPTS,
EMAIL_SMTP_SERVER,
EMAIL_SMTP_PORT,
EMAIL_SMTP_USERNAME,
EMAIL_SMTP_PASSWORD,
EMAIL_SMTP_FROM,
ENABLE_INITIAL_ADMIN_SIGNUP, ENABLE_INITIAL_ADMIN_SIGNUP,
SRC_LOG_LEVELS, SRC_LOG_LEVELS,
) )
@ -39,6 +48,11 @@ from open_webui.config import OPENID_PROVIDER_URL, ENABLE_OAUTH_SIGNUP, ENABLE_L
from pydantic import BaseModel from pydantic import BaseModel
from open_webui.utils.misc import parse_duration, validate_email_format from open_webui.utils.misc import parse_duration, validate_email_format
from open_webui.utils.email_utils import (
EmailVerificationManager,
generate_verification_code,
send_email,
)
from open_webui.utils.auth import ( from open_webui.utils.auth import (
decode_token, decode_token,
create_api_key, create_api_key,
@ -561,6 +575,91 @@ async def signin(request: Request, response: Response, form_data: SigninForm):
# SignUp # SignUp
############################ ############################
@router.post("/signup/code")
async def send_signup_code(request: Request, form_data: SignupCodeForm):
has_users = Users.has_users()
if WEBUI_AUTH:
if (
not request.app.state.config.ENABLE_SIGNUP
or not request.app.state.config.ENABLE_LOGIN_FORM
):
if has_users or not ENABLE_INITIAL_ADMIN_SIGNUP:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
else:
if has_users:
raise HTTPException(
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
)
email = form_data.email.lower()
if not validate_email_format(email):
raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
)
manager: EmailVerificationManager = getattr(
request.app.state, "email_verification_manager", None
)
if manager is None:
manager = EmailVerificationManager(request.app.state.redis)
request.app.state.email_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", {})
print(smtp_config)
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} Verification Code",
body=(
f"Your verification 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 verification email: {e}")
raise HTTPException(
status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send verification email.",
)
return {"status": True}
@router.post("/signup", response_model=SessionUserResponse) @router.post("/signup", response_model=SessionUserResponse)
async def signup(request: Request, response: Response, form_data: SignupForm): async def signup(request: Request, response: Response, form_data: SignupForm):
@ -581,14 +680,36 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.ACCESS_PROHIBITED
) )
if not validate_email_format(form_data.email.lower()): email = form_data.email.lower()
if not validate_email_format(email):
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.INVALID_EMAIL_FORMAT
) )
if Users.get_user_by_email(form_data.email.lower()): if Users.get_user_by_email(email):
raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN) raise HTTPException(400, detail=ERROR_MESSAGES.EMAIL_TAKEN)
if form_data.code is None:
raise HTTPException(
status.HTTP_400_BAD_REQUEST,
detail="Verification code is required.",
)
if form_data.code:
manager: EmailVerificationManager = getattr(
request.app.state, "email_verification_manager", None
)
if manager is None:
manager = EmailVerificationManager(request.app.state.redis)
request.app.state.email_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.",
)
try: try:
role = "admin" if not has_users else "user" role = "admin" if not has_users else "user"
@ -601,7 +722,7 @@ async def signup(request: Request, response: Response, form_data: SignupForm):
hashed = get_password_hash(form_data.password) hashed = get_password_hash(form_data.password)
user = Auths.insert_new_auth( user = Auths.insert_new_auth(
form_data.email.lower(), email,
hashed, hashed,
form_data.name, form_data.name,
form_data.profile_image_url, form_data.profile_image_url,

View file

@ -290,7 +290,8 @@ export const userSignUp = async (
name: string, name: string,
email: string, email: string,
password: string, password: string,
profile_image_url: string profile_image_url: string,
code: string
) => { ) => {
let error = null; let error = null;
@ -304,7 +305,8 @@ export const userSignUp = async (
name: name, name: name,
email: email, email: email,
password: password, password: password,
profile_image_url: profile_image_url profile_image_url: profile_image_url,
code
}) })
}) })
.then(async (res) => { .then(async (res) => {
@ -324,6 +326,33 @@ export const userSignUp = async (
return res; return res;
}; };
export const sendSignupCode = async (email: string) => {
let error = null;
const res = await fetch(`${WEBUI_API_BASE_URL}/auths/signup/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 userSignOut = async () => { export const userSignOut = async () => {
let error = null; let error = null;

View file

@ -543,7 +543,7 @@
{/if} {/if}
</div> </div>
<div class="px-2.5 max-h-64 overflow-y-auto group relative"> <div class="px-2.5 max-h-128 overflow-y-auto group relative">
<!-- Add private model button --> <!-- Add private model button -->
<button <button
class="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/60 flex items-center gap-2 transition mb-1" class="w-full text-left px-2 py-1.5 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800/60 flex items-center gap-2 transition mb-1"

View file

@ -256,6 +256,9 @@ type Config = {
enable_api_key: boolean; enable_api_key: boolean;
enable_signup: boolean; enable_signup: boolean;
enable_login_form: boolean; enable_login_form: boolean;
enable_signup_email_verification?: boolean;
enable_signup_password_confirmation?: boolean;
signup_email_verification_send_interval?: number;
enable_web_search?: boolean; enable_web_search?: boolean;
enable_google_drive_integration: boolean; enable_google_drive_integration: boolean;
enable_onedrive_integration: boolean; enable_onedrive_integration: boolean;

View file

@ -4,12 +4,18 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { onMount, getContext, tick } from 'svelte'; import { onMount, getContext, tick, onDestroy } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { page } from '$app/stores'; import { page } from '$app/stores';
import { getBackendConfig } from '$lib/apis'; import { getBackendConfig } from '$lib/apis';
import { ldapUserSignIn, getSessionUser, userSignIn, userSignUp } from '$lib/apis/auths'; import {
ldapUserSignIn,
getSessionUser,
userSignIn,
userSignUp,
sendSignupCode
} 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';
import { agreementContent as defaultAgreementContent, privacyContent as defaultPrivacyContent } from '$lib/constants/legal'; import { agreementContent as defaultAgreementContent, privacyContent as defaultPrivacyContent } from '$lib/constants/legal';
@ -35,6 +41,10 @@
let email = ''; let email = '';
let password = ''; let password = '';
let confirmPassword = ''; let confirmPassword = '';
let verificationCode = '';
let sendCodeCooldown = 0;
let sendCodeTimer: ReturnType<typeof setInterval> | null = null;
let sendingCode = false;
let ldapUsername = ''; let ldapUsername = '';
let agreeToTerms = false; let agreeToTerms = false;
@ -81,12 +91,21 @@
} }
} }
const sessionUser = await userSignUp(name, email, password, generateInitialsImage(name)).catch( if (!verificationCode) {
(error) => { toast.error('请输入邮箱验证码');
toast.error(`${error}`); return;
return null; }
}
); const sessionUser = await userSignUp(
name,
email,
password,
generateInitialsImage(name),
verificationCode
).catch((error) => {
toast.error(`${error}`);
return null;
});
await setSessionUser(sessionUser); await setSessionUser(sessionUser);
}; };
@ -99,6 +118,51 @@
await setSessionUser(sessionUser); await setSessionUser(sessionUser);
}; };
const clearSendCodeTimer = () => {
if (sendCodeTimer) {
clearInterval(sendCodeTimer);
sendCodeTimer = null;
}
};
const startSendCodeCooldown = (seconds: number) => {
clearSendCodeTimer();
sendCodeCooldown = seconds;
sendCodeTimer = setInterval(() => {
if (sendCodeCooldown <= 1) {
clearSendCodeTimer();
sendCodeCooldown = 0;
} else {
sendCodeCooldown -= 1;
}
}, 1000);
};
const sendCodeHandler = async () => {
if (!email) {
toast.error('请先填写邮箱');
return;
}
if (sendCodeCooldown > 0 || sendingCode) {
return;
}
sendingCode = true;
const res = await sendSignupCode(email).catch((error) => {
toast.error(`${error}`);
return null;
});
sendingCode = false;
if (res) {
toast.success('验证码已发送,请查收邮箱');
const interval = $config?.features?.signup_email_verification_send_interval ?? 60;
startSendCodeCooldown(interval);
}
};
const submitHandler = async () => { const submitHandler = async () => {
if (mode === 'ldap') { if (mode === 'ldap') {
await ldapSignInHandler(); await ldapSignInHandler();
@ -196,6 +260,10 @@
onboarding = $config?.onboarding ?? false; onboarding = $config?.onboarding ?? false;
} }
}); });
onDestroy(() => {
clearSendCodeTimer();
});
</script> </script>
<svelte:head> <svelte:head>
@ -332,6 +400,37 @@
required required
/> />
</div> </div>
{#if mode === 'signup'}
<div class="mb-2">
<label for="verification-code" class="text-sm font-medium text-left mb-1 block"
>邮箱验证码</label
>
<div class="flex gap-2">
<input
bind:value={verificationCode}
type="text"
id="verification-code"
class="my-0.5 w-full text-sm outline-hidden bg-transparent placeholder:text-gray-300 dark:placeholder:text-gray-600"
placeholder="请输入邮箱验证码"
autocomplete="one-time-code"
/>
<button
type="button"
class="shrink-0 px-3 rounded-full border-1 text-sm font-medium transition-all hover:bg-gray-700/10 dark:hover:bg-gray-100/10"
disabled={sendCodeCooldown > 0 || sendingCode}
on:click={sendCodeHandler}
>
{#if sendingCode}
发送中...
{:else if sendCodeCooldown > 0}
{sendCodeCooldown}s
{:else}
发送验证码
{/if}
</button>
</div>
</div>
{/if}
{/if} {/if}
<div> <div>