From e10f7957894e1e375f8400b52cac7770f961c690 Mon Sep 17 00:00:00 2001 From: Gaofeng Date: Tue, 2 Dec 2025 23:31:31 +0800 Subject: [PATCH] =?UTF-8?q?-=20=E6=B3=A8=E5=86=8C=E5=BC=BA=E5=88=B6?= =?UTF-8?q?=E9=82=AE=E7=AE=B1=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=9A=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E6=96=B0=E5=A2=9E=E9=AA=8C=E8=AF=81=E7=A0=81=E5=8F=91?= =?UTF-8?q?=E9=80=81/=E6=A0=A1=E9=AA=8C=E9=93=BE=E8=B7=AF=EF=BC=8C/api/aut?= =?UTF-8?q?hs/signup/code=20=E5=8F=91=E9=80=81=E9=AA=8C=E8=AF=81=E7=A0=81?= =?UTF-8?q?=EF=BC=8C/api/auths/signup=20=E5=BF=85=E5=A1=AB=20code=20?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=90=8E=E6=89=8D=E5=88=9B=E5=BB=BA=E7=94=A8?= =?UTF-8?q?=E6=88=B7=EF=BC=88=E9=A6=96=E4=B8=AA=E7=94=A8=E6=88=B7=E4=BA=A6?= =?UTF-8?q?=E9=9C=80=E9=AA=8C=E8=AF=81=E7=A0=81=EF=BC=89=E3=80=82=20-=20?= =?UTF-8?q?=E9=82=AE=E4=BB=B6=E5=8F=91=E9=80=81=E6=94=B9=E4=B8=BA=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=20SMTPS(SSL)=EF=BC=9A=E6=96=B0=E5=A2=9E=20backend/ope?= =?UTF-8?q?n=5Fwebui/utils/email=5Futils.py=EF=BC=8C=E4=BD=BF=E7=94=A8=20S?= =?UTF-8?q?MTP=5FSSL=20=E7=9B=B4=E8=BF=9E=EF=BC=9B=E5=8E=BB=E6=8E=89=20TLS?= =?UTF-8?q?=20=E5=BC=80=E5=85=B3=EF=BC=8CSMTP=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=8F=AA=E5=90=AB=20server/port/username/password/from?= =?UTF-8?q?=E3=80=82=20-=20=E9=85=8D=E7=BD=AE=E6=94=B9=E5=8A=A8=EF=BC=9ASM?= =?UTF-8?q?TP=20=E7=9B=B8=E5=85=B3=E7=8E=AF=E5=A2=83=E5=8F=98=E9=87=8F?= =?UTF-8?q?=E4=BB=85=E9=9C=80=E8=AE=BE=E5=AE=9A=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E3=80=81=E7=AB=AF=E5=8F=A3=E3=80=81=E8=B4=A6=E6=88=B7=E3=80=81?= =?UTF-8?q?=E5=AF=86=E7=A0=81=E3=80=81=E5=8F=91=E4=BF=A1=E4=BA=BA=E3=80=82?= =?UTF-8?q?=20-=20=E5=89=8D=E7=AB=AF=E6=B3=A8=E5=86=8C=E9=A1=B5=EF=BC=9A?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E7=A0=81=E8=BE=93=E5=85=A5=E4=B8=8E=E5=8F=91?= =?UTF-8?q?=E9=80=81=E6=8C=89=E9=92=AE=E5=A7=8B=E7=BB=88=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=EF=BC=9B=E9=AA=8C=E8=AF=81=E7=A0=81=E5=BF=85=E5=A1=AB=EF=BC=9B?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20sendSignupCode=20API=20=E8=B0=83=E7=94=A8?= =?UTF-8?q?=EF=BC=9BuserSignUp=20=E5=BF=85=E4=BC=A0=E9=AA=8C=E8=AF=81?= =?UTF-8?q?=E7=A0=81=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/open_webui/env.py | 53 ++++++++ backend/open_webui/main.py | 56 +++++--- backend/open_webui/models/auths.py | 5 + backend/open_webui/routers/auths.py | 127 +++++++++++++++++- src/lib/apis/auths/index.ts | 33 ++++- .../chat/ModelSelector/Selector.svelte | 2 +- src/lib/stores/index.ts | 3 + src/routes/auth/+page.svelte | 115 ++++++++++++++-- 8 files changed, 365 insertions(+), 29 deletions(-) diff --git a/backend/open_webui/env.py b/backend/open_webui/env.py index 4c813a8ae6..1960fde666 100644 --- a/backend/open_webui/env.py +++ b/backend/open_webui/env.py @@ -417,6 +417,59 @@ ENABLE_SIGNUP_PASSWORD_CONFIRMATION = ( os.environ.get("ENABLE_SIGNUP_PASSWORD_CONFIRMATION", "False").lower() == "true" ) +# 注册邮箱验证码相关配置(强制开启,不再提供开关) +# - EMAIL_SMTP_SERVER:SMTP 服务器地址,必填 +# - EMAIL_SMTP_PORT:SMTP 端口(默认 587) +# - EMAIL_SMTP_USERNAME:SMTP 登录用户名 +# - EMAIL_SMTP_PASSWORD:SMTP 登录密码 +# - 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", None ) diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index d9d6ee8842..95e98d489f 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -441,6 +441,14 @@ from open_webui.env import ( WEBUI_SESSION_COOKIE_SAME_SITE, WEBUI_SESSION_COOKIE_SECURE, 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_NAME_HEADER, WEBUI_AUTH_SIGNOUT_REDIRECT_URL, @@ -465,6 +473,7 @@ from open_webui.utils.models import ( check_model_access, get_filtered_models, ) +from open_webui.utils.email_utils import EmailVerificationManager from open_webui.utils.chat import ( generate_chat_completion as chat_completion_handler, chat_completed as chat_completed_handler, @@ -571,6 +580,7 @@ async def lifespan(app: FastAPI): redis_cluster=REDIS_CLUSTER, async_mode=True, ) + app.state.email_verification_manager = EmailVerificationManager(app.state.redis) if app.state.redis is not None: 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.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_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS @@ -1870,26 +1894,28 @@ async def get_app_config(request: Request): return { **({"onboarding": True} if onboarding else {}), - "status": True, - "name": app.state.WEBUI_NAME, - "version": VERSION, - "default_locale": str(DEFAULT_LOCALE), - "oauth": { + "status": True, + "name": app.state.WEBUI_NAME, + "version": VERSION, + "default_locale": str(DEFAULT_LOCALE), + "oauth": { "providers": { name: config.get("name", name) for name, config in OAUTH_PROVIDERS.items() } }, - "features": { - "auth": WEBUI_AUTH, - "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), - "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, - "enable_ldap": app.state.config.ENABLE_LDAP, - "enable_api_key": app.state.config.ENABLE_API_KEY, - "enable_signup": app.state.config.ENABLE_SIGNUP, - "enable_login_form": app.state.config.ENABLE_LOGIN_FORM, - "enable_websocket": ENABLE_WEBSOCKET_SUPPORT, - "enable_version_update_check": ENABLE_VERSION_UPDATE_CHECK, + "features": { + "auth": WEBUI_AUTH, + "auth_trusted_header": bool(app.state.AUTH_TRUSTED_EMAIL_HEADER), + "enable_signup_password_confirmation": ENABLE_SIGNUP_PASSWORD_CONFIRMATION, + "enable_signup_email_verification": True, + "enable_ldap": app.state.config.ENABLE_LDAP, + "enable_api_key": app.state.config.ENABLE_API_KEY, + "enable_signup": app.state.config.ENABLE_SIGNUP, + "enable_login_form": app.state.config.ENABLE_LOGIN_FORM, + "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, diff --git a/backend/open_webui/models/auths.py b/backend/open_webui/models/auths.py index 6517e21345..0a2f3d0b2d 100644 --- a/backend/open_webui/models/auths.py +++ b/backend/open_webui/models/auths.py @@ -83,6 +83,11 @@ class SignupForm(BaseModel): email: str password: str profile_image_url: Optional[str] = "/user.png" + code: str + + +class SignupCodeForm(BaseModel): + email: str class AddUserForm(SignupForm): diff --git a/backend/open_webui/routers/auths.py b/backend/open_webui/routers/auths.py index c031d74b79..0d87b54010 100644 --- a/backend/open_webui/routers/auths.py +++ b/backend/open_webui/routers/auths.py @@ -14,6 +14,7 @@ from open_webui.models.auths import ( SigninForm, SigninResponse, SignupForm, + SignupCodeForm, UpdatePasswordForm, UserResponse, ) @@ -30,6 +31,14 @@ from open_webui.env import ( WEBUI_AUTH_COOKIE_SAME_SITE, WEBUI_AUTH_COOKIE_SECURE, 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, 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 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 ( decode_token, create_api_key, @@ -561,6 +575,91 @@ async def signin(request: Request, response: Response, form_data: SigninForm): # 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) 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 ) - if not validate_email_format(form_data.email.lower()): + email = form_data.email.lower() + + if not validate_email_format(email): raise HTTPException( 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) + 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: 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) user = Auths.insert_new_auth( - form_data.email.lower(), + email, hashed, form_data.name, form_data.profile_image_url, diff --git a/src/lib/apis/auths/index.ts b/src/lib/apis/auths/index.ts index 5450479af5..bd4e0b64b2 100644 --- a/src/lib/apis/auths/index.ts +++ b/src/lib/apis/auths/index.ts @@ -290,7 +290,8 @@ export const userSignUp = async ( name: string, email: string, password: string, - profile_image_url: string + profile_image_url: string, + code: string ) => { let error = null; @@ -304,7 +305,8 @@ export const userSignUp = async ( name: name, email: email, password: password, - profile_image_url: profile_image_url + profile_image_url: profile_image_url, + code }) }) .then(async (res) => { @@ -324,6 +326,33 @@ export const userSignUp = async ( 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 () => { let error = null; diff --git a/src/lib/components/chat/ModelSelector/Selector.svelte b/src/lib/components/chat/ModelSelector/Selector.svelte index 2a4ec93185..9e6ef0114a 100644 --- a/src/lib/components/chat/ModelSelector/Selector.svelte +++ b/src/lib/components/chat/ModelSelector/Selector.svelte @@ -543,7 +543,7 @@ {/if} -
+
+ {#if mode === 'signup'} +
+ +
+ + +
+
+ {/if} {/if}