Coverage for open_webui/models/auths.py: 67%
123 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 05:07 +0000
1"""Auth credential models and data-access layer."""
3from __future__ import annotations
5import logging
6import uuid
7from typing import Optional
9import bcrypt
10from open_webui.internal.db import Base, JSONField, get_async_db_context
11from open_webui.models.users import User, UserModel, UserProfileImageResponse, Users
12from open_webui.utils.validate import validate_image_url
13from pydantic import BaseModel, field_validator
14from sqlalchemy import Boolean, Column, String, Text, delete, select, update
15from sqlalchemy.exc import IntegrityError
16from sqlalchemy.ext.asyncio import AsyncSession
18log = logging.getLogger(__name__)
20# Pre-computed hash verified on signin paths that lack a real credential
21# (unknown user, inactive account) so response timing cannot reveal
22# whether an account exists (CWE-208).
23PLACEHOLDER_HASH = bcrypt.hashpw(b'placeholder', bcrypt.gensalt()).decode('utf-8')
26class Auth(Base): # credential ↔ user linkage
27 """Maps a user ID to an email/password pair with an active flag."""
29 __tablename__ = 'auth'
31 id = Column(String, primary_key=True, unique=True) # mirrors User.id
32 email = Column(String) # login address, kept in sync with User.email
33 password = Column(Text) # argon2 / bcrypt hash
34 active = Column(Boolean) # account soft-disable toggle
37class AuthModel(BaseModel):
38 """Pydantic mirror of the ``auth`` table row."""
40 id: str
41 email: str
42 password: str
43 active: bool = True
46class Token(BaseModel):
47 """JWT bearer-token response wrapper."""
49 token: str
50 token_type: str
53class ApiKey(BaseModel):
54 api_key: str | None = None
57class SigninResponse(Token, UserProfileImageResponse):
58 pass
61class SigninForm(BaseModel):
62 email: str
63 password: str
66class LdapForm(BaseModel):
67 user: str
68 password: str
71class ProfileImageUrlForm(BaseModel):
72 profile_image_url: str
75class UpdatePasswordForm(BaseModel):
76 password: str
77 new_password: str
80class SignupForm(BaseModel):
81 name: str
82 email: str
83 password: str
84 profile_image_url: str | None = '/user.png'
86 @field_validator('profile_image_url')
87 @classmethod
88 def check_profile_image_url(cls, v: str | None) -> str | None:
89 if v is not None:
90 return validate_image_url(v)
91 return v
94class AddUserForm(SignupForm):
95 role: str | None = 'pending'
98# --- data-access layer ---
101class AuthsTable:
102 """Provides CRUD operations for the Auth ↔ User lifecycle."""
104 async def insert_new_auth(
105 self,
106 email: str,
107 password: str,
108 name: str,
109 profile_image_url: str = '/user.png',
110 role: str = 'pending',
111 oauth: dict | None = None,
112 db: AsyncSession | None = None,
113 ) -> UserModel | None:
114 """Create an Auth + User pair inside a single transaction."""
115 async with get_async_db_context(db) as session:
116 log.info('insert_new_auth')
118 new_id = str(uuid.uuid4())
120 credential = Auth(
121 id=new_id,
122 email=email,
123 password=password,
124 active=True,
125 )
126 session.add(credential)
128 try:
129 created_user = await Users.insert_new_user(
130 new_id,
131 name,
132 email,
133 profile_image_url,
134 role,
135 oauth=oauth,
136 db=session,
137 )
138 await session.commit()
139 except IntegrityError:
140 await session.rollback()
141 raise
142 return created_user if credential and created_user else None
144 async def authenticate_user(
145 self,
146 email: str,
147 verify_password: callable,
148 db: AsyncSession | None = None,
149 ) -> UserModel | None:
150 """Verify email + password credentials and return the matching user."""
151 log.info('authenticate_user: %s', email)
152 resolved = await Users.get_user_by_email(email, db=db)
153 if not resolved:
154 await verify_password(PLACEHOLDER_HASH)
155 return
156 # load the credential row and verify the password hash
157 async with get_async_db_context(db) as session:
158 credential = await session.get(Auth, resolved.id)
159 if not credential or not credential.active:
160 await verify_password(PLACEHOLDER_HASH)
161 return
162 if not await verify_password(credential.password):
163 return
164 return resolved
166 async def authenticate_user_by_api_key(
167 self,
168 api_key: str,
169 db: AsyncSession | None = None,
170 ) -> UserModel | None:
171 """Look up the user that owns the given API key."""
172 log.info('authenticate_user_by_api_key')
173 if not api_key:
174 return
175 # delegate to the Users model for the actual lookup
176 return await Users.get_user_by_api_key(api_key, db=db)
178 async def authenticate_user_by_email(
179 self,
180 email: str,
181 db: AsyncSession | None = None,
182 ) -> UserModel | None:
183 """Single-query auth via JOIN on Auth ↔ User, filtered by active flag."""
184 log.info('authenticate_user_by_email: %s', email)
185 # single JOIN avoids N+1 — returns (Auth, User) tuple or None
186 async with get_async_db_context(db) as session:
187 joined_query = (
188 select(Auth, User).join(User, Auth.id == User.id).where(Auth.email == email, Auth.active.is_(True))
189 )
190 match = (await session.execute(joined_query)).first()
191 if not match:
192 return
193 _, found_user = match
194 return UserModel.model_validate(found_user)
196 async def update_email_by_id(
197 self,
198 user_id: str,
199 email: str,
200 db: AsyncSession | None = None,
201 ) -> bool:
202 """Set a new email on the auth record and propagate to the user row."""
203 async with get_async_db_context(db) as session:
204 auth_row = await session.get(Auth, user_id)
205 if auth_row is None:
206 return False
207 auth_row.email = email
208 await session.commit()
209 await Users.update_user_by_id(user_id, {'email': email}, db=session)
210 return True
211 # --- password modification ---
213 async def update_user_password_by_id(
214 self,
215 user_id: str,
216 new_password: str,
217 db: AsyncSession | None = None,
218 ) -> bool:
219 """Set a new password hash for an existing user."""
220 async with get_async_db_context(db) as session:
221 auth_row = await session.get(Auth, user_id)
222 if auth_row is None:
223 return False
224 auth_row.password = new_password
225 await session.commit()
226 return True
228 async def delete_auth_by_id(
229 self,
230 id: str,
231 db: AsyncSession | None = None,
232 ) -> bool:
233 """Remove a user and their auth credential in one transaction."""
234 async with get_async_db_context(db) as session:
235 if not await Users.delete_user_by_id(id, db=session): 235 ↛ 236line 235 didn't jump to line 236 because the condition on line 235 was never true
236 return False
237 await session.execute(delete(Auth).where(Auth.id == id))
238 await session.commit()
239 return True
242Auths = AuthsTable() # singleton — module-level instance