forked from open-webui/open-webui
		
	Merge pull request #1347 from cheahjs/feat/trusted-email-header
feat: allow authenticating with a trusted email header
This commit is contained in:
		
						commit
						24fb77759e
					
				
					 9 changed files with 158 additions and 99 deletions
				
			
		|  | @ -26,6 +26,7 @@ ENV OPENAI_API_BASE_URL "" | ||||||
| ENV OPENAI_API_KEY "" | ENV OPENAI_API_KEY "" | ||||||
| 
 | 
 | ||||||
| ENV WEBUI_SECRET_KEY "" | ENV WEBUI_SECRET_KEY "" | ||||||
|  | ENV WEBUI_AUTH_TRUSTED_EMAIL_HEADER "" | ||||||
| 
 | 
 | ||||||
| ENV SCARF_NO_ANALYTICS true | ENV SCARF_NO_ANALYTICS true | ||||||
| ENV DO_NOT_TRACK true | ENV DO_NOT_TRACK true | ||||||
|  |  | ||||||
|  | @ -20,6 +20,7 @@ from config import ( | ||||||
|     ENABLE_SIGNUP, |     ENABLE_SIGNUP, | ||||||
|     USER_PERMISSIONS, |     USER_PERMISSIONS, | ||||||
|     WEBHOOK_URL, |     WEBHOOK_URL, | ||||||
|  |     WEBUI_AUTH_TRUSTED_EMAIL_HEADER, | ||||||
| ) | ) | ||||||
| 
 | 
 | ||||||
| app = FastAPI() | app = FastAPI() | ||||||
|  | @ -34,7 +35,7 @@ app.state.DEFAULT_PROMPT_SUGGESTIONS = DEFAULT_PROMPT_SUGGESTIONS | ||||||
| app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE | app.state.DEFAULT_USER_ROLE = DEFAULT_USER_ROLE | ||||||
| app.state.USER_PERMISSIONS = USER_PERMISSIONS | app.state.USER_PERMISSIONS = USER_PERMISSIONS | ||||||
| app.state.WEBHOOK_URL = WEBHOOK_URL | app.state.WEBHOOK_URL = WEBHOOK_URL | ||||||
| 
 | app.state.AUTH_TRUSTED_EMAIL_HEADER = WEBUI_AUTH_TRUSTED_EMAIL_HEADER | ||||||
| 
 | 
 | ||||||
| app.add_middleware( | app.add_middleware( | ||||||
|     CORSMiddleware, |     CORSMiddleware, | ||||||
|  |  | ||||||
|  | @ -123,6 +123,16 @@ class AuthsTable: | ||||||
|         except: |         except: | ||||||
|             return None |             return None | ||||||
| 
 | 
 | ||||||
|  |     def authenticate_user_by_trusted_header(self, email: str) -> Optional[UserModel]: | ||||||
|  |         log.info(f"authenticate_user_by_trusted_header: {email}") | ||||||
|  |         try: | ||||||
|  |             auth = Auth.get(Auth.email == email, Auth.active == True) | ||||||
|  |             if auth: | ||||||
|  |                 user = Users.get_user_by_id(auth.id) | ||||||
|  |                 return user | ||||||
|  |         except: | ||||||
|  |             return None | ||||||
|  | 
 | ||||||
|     def update_user_password_by_id(self, id: str, new_password: str) -> bool: |     def update_user_password_by_id(self, id: str, new_password: str) -> bool: | ||||||
|         try: |         try: | ||||||
|             query = Auth.update(password=new_password).where(Auth.id == id) |             query = Auth.update(password=new_password).where(Auth.id == id) | ||||||
|  |  | ||||||
|  | @ -29,6 +29,7 @@ from utils.utils import ( | ||||||
| from utils.misc import parse_duration, validate_email_format | from utils.misc import parse_duration, validate_email_format | ||||||
| from utils.webhook import post_webhook | from utils.webhook import post_webhook | ||||||
| from constants import ERROR_MESSAGES, WEBHOOK_MESSAGES | from constants import ERROR_MESSAGES, WEBHOOK_MESSAGES | ||||||
|  | from config import WEBUI_AUTH_TRUSTED_EMAIL_HEADER | ||||||
| 
 | 
 | ||||||
| router = APIRouter() | router = APIRouter() | ||||||
| 
 | 
 | ||||||
|  | @ -79,6 +80,8 @@ async def update_profile( | ||||||
| async def update_password( | async def update_password( | ||||||
|     form_data: UpdatePasswordForm, session_user=Depends(get_current_user) |     form_data: UpdatePasswordForm, session_user=Depends(get_current_user) | ||||||
| ): | ): | ||||||
|  |     if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: | ||||||
|  |         raise HTTPException(400, detail=ERROR_MESSAGES.ACTION_PROHIBITED) | ||||||
|     if session_user: |     if session_user: | ||||||
|         user = Auths.authenticate_user(session_user.email, form_data.password) |         user = Auths.authenticate_user(session_user.email, form_data.password) | ||||||
| 
 | 
 | ||||||
|  | @ -98,7 +101,22 @@ async def update_password( | ||||||
| 
 | 
 | ||||||
| @router.post("/signin", response_model=SigninResponse) | @router.post("/signin", response_model=SigninResponse) | ||||||
| async def signin(request: Request, form_data: SigninForm): | async def signin(request: Request, form_data: SigninForm): | ||||||
|     user = Auths.authenticate_user(form_data.email.lower(), form_data.password) |     if WEBUI_AUTH_TRUSTED_EMAIL_HEADER: | ||||||
|  |         if WEBUI_AUTH_TRUSTED_EMAIL_HEADER not in request.headers: | ||||||
|  |             raise HTTPException(400, detail=ERROR_MESSAGES.INVALID_TRUSTED_HEADER) | ||||||
|  | 
 | ||||||
|  |         trusted_email = request.headers[WEBUI_AUTH_TRUSTED_EMAIL_HEADER].lower() | ||||||
|  |         if not Users.get_user_by_email(trusted_email.lower()): | ||||||
|  |             await signup( | ||||||
|  |                 request, | ||||||
|  |                 SignupForm( | ||||||
|  |                     email=trusted_email, password=str(uuid.uuid4()), name=trusted_email | ||||||
|  |                 ), | ||||||
|  |             ) | ||||||
|  |         user = Auths.authenticate_user_by_trusted_header(trusted_email) | ||||||
|  |     else: | ||||||
|  |         user = Auths.authenticate_user(form_data.email.lower(), form_data.password) | ||||||
|  | 
 | ||||||
|     if user: |     if user: | ||||||
|         token = create_token( |         token = create_token( | ||||||
|             data={"id": user.id}, |             data={"id": user.id}, | ||||||
|  |  | ||||||
|  | @ -362,6 +362,9 @@ WEBUI_VERSION = os.environ.get("WEBUI_VERSION", "v1.0.0-alpha.100") | ||||||
| #################################### | #################################### | ||||||
| 
 | 
 | ||||||
| WEBUI_AUTH = True | WEBUI_AUTH = True | ||||||
|  | WEBUI_AUTH_TRUSTED_EMAIL_HEADER = os.environ.get( | ||||||
|  |     "WEBUI_AUTH_TRUSTED_EMAIL_HEADER", None | ||||||
|  | ) | ||||||
| 
 | 
 | ||||||
| #################################### | #################################### | ||||||
| # WEBUI_SECRET_KEY | # WEBUI_SECRET_KEY | ||||||
|  |  | ||||||
|  | @ -20,6 +20,7 @@ class ERROR_MESSAGES(str, Enum): | ||||||
|     ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." |     ENV_VAR_NOT_FOUND = "Required environment variable not found. Terminating now." | ||||||
|     CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance." |     CREATE_USER_ERROR = "Oops! Something went wrong while creating your account. Please try again later. If the issue persists, contact support for assistance." | ||||||
|     DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot." |     DELETE_USER_ERROR = "Oops! Something went wrong. We encountered an issue while trying to delete the user. Please give it another shot." | ||||||
|  |     EMAIL_MISMATCH = "Uh-oh! This email does not match the email your provider is registered with. Please check your email and try again." | ||||||
|     EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew." |     EMAIL_TAKEN = "Uh-oh! This email is already registered. Sign in with your existing account or choose another email to start anew." | ||||||
|     USERNAME_TAKEN = ( |     USERNAME_TAKEN = ( | ||||||
|         "Uh-oh! This username is already registered. Please choose another username." |         "Uh-oh! This username is already registered. Please choose another username." | ||||||
|  | @ -36,6 +37,7 @@ class ERROR_MESSAGES(str, Enum): | ||||||
|     INVALID_PASSWORD = ( |     INVALID_PASSWORD = ( | ||||||
|         "The password provided is incorrect. Please check for typos and try again." |         "The password provided is incorrect. Please check for typos and try again." | ||||||
|     ) |     ) | ||||||
|  |     INVALID_TRUSTED_HEADER = "Your provider has not provided a trusted header. Please contact your administrator for assistance." | ||||||
|     UNAUTHORIZED = "401 Unauthorized" |     UNAUTHORIZED = "401 Unauthorized" | ||||||
|     ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance." |     ACCESS_PROHIBITED = "You do not have permission to access this resource. Please contact your administrator for assistance." | ||||||
|     ACTION_PROHIBITED = ( |     ACTION_PROHIBITED = ( | ||||||
|  |  | ||||||
|  | @ -194,6 +194,7 @@ async def get_app_config(): | ||||||
|         "images": images_app.state.ENABLED, |         "images": images_app.state.ENABLED, | ||||||
|         "default_models": webui_app.state.DEFAULT_MODELS, |         "default_models": webui_app.state.DEFAULT_MODELS, | ||||||
|         "default_prompt_suggestions": webui_app.state.DEFAULT_PROMPT_SUGGESTIONS, |         "default_prompt_suggestions": webui_app.state.DEFAULT_PROMPT_SUGGESTIONS, | ||||||
|  |         "trusted_header_auth": bool(webui_app.state.AUTH_TRUSTED_EMAIL_HEADER), | ||||||
|     } |     } | ||||||
| 
 | 
 | ||||||
| 
 | 
 | ||||||
|  |  | ||||||
|  | @ -1,24 +1,25 @@ | ||||||
| <script lang="ts"> | <script lang="ts"> | ||||||
| 	export let className: string = 'text-white'; | 	export let className: string = ''; | ||||||
| 	export let theme: 'blue' | 'white' | 'black' = 'white'; |  | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
| <div class="flex justify-center text-center {className}"> | <div class="flex justify-center text-center {className}"> | ||||||
| 	<svg | 	<svg class="size-5" viewBox="0 0 24 24" fill="currentColor" xmlns="http://www.w3.org/2000/svg" | ||||||
| 		class="animate-spin -ml-1 mr-3 h-5 w-5 {theme === 'blue' | 		><style> | ||||||
| 			? 'text-sky-600' | 			.spinner_ajPY { | ||||||
| 			: theme === 'white' | 				transform-origin: center; | ||||||
| 			? 'text-white' | 				animation: spinner_AtaB 0.75s infinite linear; | ||||||
| 			: 'text-gray-600'} " | 			} | ||||||
| 		xmlns="http://www.w3.org/2000/svg" | 			@keyframes spinner_AtaB { | ||||||
| 		fill="none" | 				100% { | ||||||
| 		viewBox="0 0 24 24" | 					transform: rotate(360deg); | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 		</style><path | ||||||
|  | 			d="M12,1A11,11,0,1,0,23,12,11,11,0,0,0,12,1Zm0,19a8,8,0,1,1,8-8A8,8,0,0,1,12,20Z" | ||||||
|  | 			opacity=".25" | ||||||
|  | 		/><path | ||||||
|  | 			d="M10.14,1.16a11,11,0,0,0-9,8.92A1.59,1.59,0,0,0,2.46,12,1.52,1.52,0,0,0,4.11,10.7a8,8,0,0,1,6.66-6.61A1.42,1.42,0,0,0,12,2.69h0A1.57,1.57,0,0,0,10.14,1.16Z" | ||||||
|  | 			class="spinner_ajPY" | ||||||
|  | 		/></svg | ||||||
| 	> | 	> | ||||||
| 		<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" /> |  | ||||||
| 		<path |  | ||||||
| 			class="opacity-75" |  | ||||||
| 			fill="currentColor" |  | ||||||
| 			d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" |  | ||||||
| 		/> |  | ||||||
| 	</svg> |  | ||||||
| </div> | </div> | ||||||
|  |  | ||||||
|  | @ -1,6 +1,7 @@ | ||||||
| <script> | <script> | ||||||
| 	import { goto } from '$app/navigation'; | 	import { goto } from '$app/navigation'; | ||||||
| 	import { userSignIn, userSignUp } from '$lib/apis/auths'; | 	import { userSignIn, userSignUp } from '$lib/apis/auths'; | ||||||
|  | 	import Spinner from '$lib/components/common/Spinner.svelte'; | ||||||
| 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; | 	import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants'; | ||||||
| 	import { WEBUI_NAME, config, user } from '$lib/stores'; | 	import { WEBUI_NAME, config, user } from '$lib/stores'; | ||||||
| 	import { onMount, getContext } from 'svelte'; | 	import { onMount, getContext } from 'svelte'; | ||||||
|  | @ -56,6 +57,9 @@ | ||||||
| 			await goto('/'); | 			await goto('/'); | ||||||
| 		} | 		} | ||||||
| 		loaded = true; | 		loaded = true; | ||||||
|  | 		if ($config?.trusted_header_auth ?? false) { | ||||||
|  | 			await signInHandler(); | ||||||
|  | 		} | ||||||
| 	}); | 	}); | ||||||
| </script> | </script> | ||||||
| 
 | 
 | ||||||
|  | @ -90,100 +94,118 @@ | ||||||
| 		</div> --> | 		</div> --> | ||||||
| 
 | 
 | ||||||
| 		<div class="w-full sm:max-w-lg px-4 min-h-screen flex flex-col"> | 		<div class="w-full sm:max-w-lg px-4 min-h-screen flex flex-col"> | ||||||
| 			<div class=" my-auto pb-10 w-full"> | 			{#if $config?.trusted_header_auth ?? false} | ||||||
| 				<form | 				<div class=" my-auto pb-10 w-full"> | ||||||
| 					class=" flex flex-col justify-center bg-white py-6 sm:py-16 px-6 sm:px-16 rounded-2xl" | 					<div | ||||||
| 					on:submit|preventDefault={() => { | 						class="flex items-center justify-center gap-3 text-xl sm:text-2xl text-center font-bold dark:text-gray-200" | ||||||
| 						submitHandler(); | 					> | ||||||
| 					}} | 						<div> | ||||||
| 				> | 							{$i18n.t('Signing in')} | ||||||
| 					<div class=" text-xl sm:text-2xl font-bold"> | 							{$i18n.t('to')} | ||||||
| 						{mode === 'signin' ? $i18n.t('Sign in') : $i18n.t('Sign up')} | 							{$WEBUI_NAME} | ||||||
| 						{$i18n.t('to')} | 						</div> | ||||||
| 						{$WEBUI_NAME} | 
 | ||||||
| 					</div> | 						<div> | ||||||
| 
 | 							<Spinner /> | ||||||
| 					{#if mode === 'signup'} | 						</div> | ||||||
| 						<div class=" mt-1 text-xs font-medium text-gray-500"> | 					</div> | ||||||
| 							ⓘ {$WEBUI_NAME} | 				</div> | ||||||
| 							{$i18n.t( | 			{:else} | ||||||
| 								'does not make any external connections, and your data stays securely on your locally hosted server.' | 				<div class=" my-auto pb-10 w-full"> | ||||||
| 							)} | 					<form | ||||||
|  | 						class=" flex flex-col justify-center bg-white py-6 sm:py-16 px-6 sm:px-16 rounded-2xl" | ||||||
|  | 						on:submit|preventDefault={() => { | ||||||
|  | 							submitHandler(); | ||||||
|  | 						}} | ||||||
|  | 					> | ||||||
|  | 						<div class=" text-xl sm:text-2xl font-bold"> | ||||||
|  | 							{mode === 'signin' ? $i18n.t('Sign in') : $i18n.t('Sign up')} | ||||||
|  | 							{$i18n.t('to')} | ||||||
|  | 							{$WEBUI_NAME} | ||||||
| 						</div> | 						</div> | ||||||
| 					{/if} |  | ||||||
| 
 | 
 | ||||||
| 					<div class="flex flex-col mt-4"> |  | ||||||
| 						{#if mode === 'signup'} | 						{#if mode === 'signup'} | ||||||
| 							<div> | 							<div class=" mt-1 text-xs font-medium text-gray-500"> | ||||||
| 								<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Name')}</div> | 								ⓘ {$WEBUI_NAME} | ||||||
|  | 								{$i18n.t( | ||||||
|  | 									'does not make any external connections, and your data stays securely on your locally hosted server.' | ||||||
|  | 								)} | ||||||
|  | 							</div> | ||||||
|  | 						{/if} | ||||||
|  | 
 | ||||||
|  | 						<div class="flex flex-col mt-4"> | ||||||
|  | 							{#if mode === 'signup'} | ||||||
|  | 								<div> | ||||||
|  | 									<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Name')}</div> | ||||||
|  | 									<input | ||||||
|  | 										bind:value={name} | ||||||
|  | 										type="text" | ||||||
|  | 										class=" border px-4 py-2.5 rounded-2xl w-full text-sm" | ||||||
|  | 										autocomplete="name" | ||||||
|  | 										placeholder={$i18n.t('Enter Your Full Name')} | ||||||
|  | 										required | ||||||
|  | 									/> | ||||||
|  | 								</div> | ||||||
|  | 
 | ||||||
|  | 								<hr class=" my-3" /> | ||||||
|  | 							{/if} | ||||||
|  | 
 | ||||||
|  | 							<div class="mb-2"> | ||||||
|  | 								<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Email')}</div> | ||||||
| 								<input | 								<input | ||||||
| 									bind:value={name} | 									bind:value={email} | ||||||
| 									type="text" | 									type="email" | ||||||
| 									class=" border px-4 py-2.5 rounded-2xl w-full text-sm" | 									class=" border px-4 py-2.5 rounded-2xl w-full text-sm" | ||||||
| 									autocomplete="name" | 									autocomplete="email" | ||||||
| 									placeholder={$i18n.t('Enter Your Full Name')} | 									placeholder={$i18n.t('Enter Your Email')} | ||||||
| 									required | 									required | ||||||
| 								/> | 								/> | ||||||
| 							</div> | 							</div> | ||||||
| 
 | 
 | ||||||
| 							<hr class=" my-3" /> | 							<div> | ||||||
| 						{/if} | 								<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Password')}</div> | ||||||
| 
 | 								<input | ||||||
| 						<div class="mb-2"> | 									bind:value={password} | ||||||
| 							<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Email')}</div> | 									type="password" | ||||||
| 							<input | 									class=" border px-4 py-2.5 rounded-2xl w-full text-sm" | ||||||
| 								bind:value={email} | 									placeholder={$i18n.t('Enter Your Password')} | ||||||
| 								type="email" | 									autocomplete="current-password" | ||||||
| 								class=" border px-4 py-2.5 rounded-2xl w-full text-sm" | 									required | ||||||
| 								autocomplete="email" | 								/> | ||||||
| 								placeholder={$i18n.t('Enter Your Email')} | 							</div> | ||||||
| 								required |  | ||||||
| 							/> |  | ||||||
| 						</div> | 						</div> | ||||||
| 
 | 
 | ||||||
| 						<div> | 						<div class="mt-5"> | ||||||
| 							<div class=" text-sm font-semibold text-left mb-1">{$i18n.t('Password')}</div> |  | ||||||
| 							<input |  | ||||||
| 								bind:value={password} |  | ||||||
| 								type="password" |  | ||||||
| 								class=" border px-4 py-2.5 rounded-2xl w-full text-sm" |  | ||||||
| 								placeholder={$i18n.t('Enter Your Password')} |  | ||||||
| 								autocomplete="current-password" |  | ||||||
| 								required |  | ||||||
| 							/> |  | ||||||
| 						</div> |  | ||||||
| 					</div> |  | ||||||
| 
 |  | ||||||
| 					<div class="mt-5"> |  | ||||||
| 						<button |  | ||||||
| 							class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-3 transition" |  | ||||||
| 							type="submit" |  | ||||||
| 						> |  | ||||||
| 							{mode === 'signin' ? $i18n.t('Sign in') : $i18n.t('Create Account')} |  | ||||||
| 						</button> |  | ||||||
| 
 |  | ||||||
| 						<div class=" mt-4 text-sm text-center"> |  | ||||||
| 							{mode === 'signin' |  | ||||||
| 								? $i18n.t("Don't have an account?") |  | ||||||
| 								: $i18n.t('Already have an account?')} |  | ||||||
| 
 |  | ||||||
| 							<button | 							<button | ||||||
| 								class=" font-medium underline" | 								class=" bg-gray-900 hover:bg-gray-800 w-full rounded-full text-white font-semibold text-sm py-3 transition" | ||||||
| 								type="button" | 								type="submit" | ||||||
| 								on:click={() => { |  | ||||||
| 									if (mode === 'signin') { |  | ||||||
| 										mode = 'signup'; |  | ||||||
| 									} else { |  | ||||||
| 										mode = 'signin'; |  | ||||||
| 									} |  | ||||||
| 								}} |  | ||||||
| 							> | 							> | ||||||
| 								{mode === 'signin' ? $i18n.t('Sign up') : $i18n.t('Sign in')} | 								{mode === 'signin' ? $i18n.t('Sign in') : $i18n.t('Create Account')} | ||||||
| 							</button> | 							</button> | ||||||
|  | 
 | ||||||
|  | 							<div class=" mt-4 text-sm text-center"> | ||||||
|  | 								{mode === 'signin' | ||||||
|  | 									? $i18n.t("Don't have an account?") | ||||||
|  | 									: $i18n.t('Already have an account?')} | ||||||
|  | 
 | ||||||
|  | 								<button | ||||||
|  | 									class=" font-medium underline" | ||||||
|  | 									type="button" | ||||||
|  | 									on:click={() => { | ||||||
|  | 										if (mode === 'signin') { | ||||||
|  | 											mode = 'signup'; | ||||||
|  | 										} else { | ||||||
|  | 											mode = 'signin'; | ||||||
|  | 										} | ||||||
|  | 									}} | ||||||
|  | 								> | ||||||
|  | 									{mode === 'signin' ? $i18n.t('Sign up') : $i18n.t('Sign in')} | ||||||
|  | 								</button> | ||||||
|  | 							</div> | ||||||
| 						</div> | 						</div> | ||||||
| 					</div> | 					</form> | ||||||
| 				</form> | 				</div> | ||||||
| 			</div> | 			{/if} | ||||||
| 		</div> | 		</div> | ||||||
| 	</div> | 	</div> | ||||||
| {/if} | {/if} | ||||||
|  |  | ||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 Timothy Jaeryang Baek
						Timothy Jaeryang Baek