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

1"""Auth credential models and data-access layer.""" 

2 

3from __future__ import annotations 

4 

5import logging 

6import uuid 

7from typing import Optional 

8 

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 

17 

18log = logging.getLogger(__name__) 

19 

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') 

24 

25 

26class Auth(Base): # credential ↔ user linkage 

27 """Maps a user ID to an email/password pair with an active flag.""" 

28 

29 __tablename__ = 'auth' 

30 

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 

35 

36 

37class AuthModel(BaseModel): 

38 """Pydantic mirror of the ``auth`` table row.""" 

39 

40 id: str 

41 email: str 

42 password: str 

43 active: bool = True 

44 

45 

46class Token(BaseModel): 

47 """JWT bearer-token response wrapper.""" 

48 

49 token: str 

50 token_type: str 

51 

52 

53class ApiKey(BaseModel): 

54 api_key: str | None = None 

55 

56 

57class SigninResponse(Token, UserProfileImageResponse): 

58 pass 

59 

60 

61class SigninForm(BaseModel): 

62 email: str 

63 password: str 

64 

65 

66class LdapForm(BaseModel): 

67 user: str 

68 password: str 

69 

70 

71class ProfileImageUrlForm(BaseModel): 

72 profile_image_url: str 

73 

74 

75class UpdatePasswordForm(BaseModel): 

76 password: str 

77 new_password: str 

78 

79 

80class SignupForm(BaseModel): 

81 name: str 

82 email: str 

83 password: str 

84 profile_image_url: str | None = '/user.png' 

85 

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 

92 

93 

94class AddUserForm(SignupForm): 

95 role: str | None = 'pending' 

96 

97 

98# --- data-access layer --- 

99 

100 

101class AuthsTable: 

102 """Provides CRUD operations for the Auth ↔ User lifecycle.""" 

103 

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') 

117 

118 new_id = str(uuid.uuid4()) 

119 

120 credential = Auth( 

121 id=new_id, 

122 email=email, 

123 password=password, 

124 active=True, 

125 ) 

126 session.add(credential) 

127 

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 

143 

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 

165 

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) 

177 

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) 

195 

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 --- 

212 

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 

227 

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 

240 

241 

242Auths = AuthsTable() # singleton — module-level instance