Coverage for .tox/coverage/lib/python3.11/site-packages/wuttjamaican/auth.py: 100%

141 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-06-15 11:33 -0500

1# -*- coding: utf-8; -*- 

2################################################################################ 

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2024 Lance Edgar 

6# 

7# This file is part of Wutta Framework. 

8# 

9# Wutta Framework is free software: you can redistribute it and/or modify it 

10# under the terms of the GNU General Public License as published by the Free 

11# Software Foundation, either version 3 of the License, or (at your option) any 

12# later version. 

13# 

14# Wutta Framework is distributed in the hope that it will be useful, but 

15# WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 

16# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for 

17# more details. 

18# 

19# You should have received a copy of the GNU General Public License along with 

20# Wutta Framework. If not, see <http://www.gnu.org/licenses/>. 

21# 

22################################################################################ 

23""" 

24Auth Handler 

25 

26This defines the default :term:`auth handler`. 

27""" 

28 

29import uuid as _uuid 

30 

31from wuttjamaican.app import GenericHandler 

32 

33 

34# nb. this only works if passlib is installed (part of 'db' extra) 

35try: 

36 from passlib.context import CryptContext 

37except ImportError: # pragma: no cover 

38 pass 

39else: 

40 password_context = CryptContext(schemes=['bcrypt']) 

41 

42 

43 

44class AuthHandler(GenericHandler): 

45 """ 

46 Base class and default implementation for the :term:`auth 

47 handler`. 

48 

49 This is responsible for "authentication and authorization" - for 

50 instance: 

51 

52 * authenticate user from login credentials 

53 * check which permissions a user/role has 

54 * create/modify users, roles 

55 * grant/revoke role permissions 

56 """ 

57 

58 def authenticate_user(self, session, username, password, **kwargs): 

59 """ 

60 Authenticate the given user credentials, and if successful, 

61 return the :class:`~wuttjamaican.db.model.auth.User`. 

62 

63 Default logic will (try to) locate a user with matching 

64 username, then confirm the supplied password is also a match. 

65 

66 Custom handlers can authenticate against anything else, using 

67 the given credentials. But they still must return a "native" 

68 ``User`` object for the app to consider the authentication 

69 successful. The handler may auto-create the user if needed. 

70 

71 Generally speaking the credentials will have come directly 

72 from a user login attempt in the web app etc. Again the 

73 default logic assumes a "username" but in practice it may be 

74 an email address etc. - whatever the user entered. 

75 

76 :param session: Open :term:`db session`. 

77 

78 :param username: Usually a string, but also may be a 

79 :class:`~wuttjamaican.db.model.auth.User` instance, in 

80 which case no user lookup will occur. (However the user is 

81 still authenticated otherwise, i.e. the password must be 

82 correct etc.) 

83 

84 :param password: Password as string. 

85 

86 :returns: :class:`~wuttjamaican.db.model.auth.User` instance, 

87 or ``None``. 

88 """ 

89 user = self.get_user(username, session=session) 

90 if user and user.active and user.password: 

91 if self.check_user_password(user, password): 

92 return user 

93 

94 def check_user_password(self, user, password, **kwargs): 

95 """ 

96 Check a user's password. 

97 

98 This will hash the given password and compare it to the hashed 

99 password we have on file for the given user account. 

100 

101 This is normally part of the login process, so the 

102 ``password`` param refers to the password entered by a user; 

103 this method will determine if it was correct. 

104 

105 :param user: :class:`~wuttjamaican.db.model.auth.User` instance. 

106 

107 :param password: User-entered password in plain text. 

108 

109 :returns: ``True`` if password matches; else ``False``. 

110 """ 

111 return password_context.verify(password, user.password) 

112 

113 def get_role(self, session, key, **kwargs): 

114 """ 

115 Locate and return a :class:`~wuttjamaican.db.model.auth.Role` 

116 per the given key, if possible. 

117 

118 :param session: Open :term:`db session`. 

119 

120 :param key: Value to use when searching for the role. Can be 

121 a UUID or name of a role. 

122 

123 :returns: :class:`~wuttjamaican.db.model.auth.Role` instance; 

124 or ``None``. 

125 """ 

126 model = self.app.model 

127 

128 if not key: 

129 return 

130 

131 # maybe it is a uuid 

132 if isinstance(key, _uuid.UUID): 

133 role = session.get(model.Role, key) 

134 if role: 

135 return role 

136 

137 else: # assuming it is a string 

138 

139 # try to match on Role.uuid 

140 try: 

141 role = session.get(model.Role, _uuid.UUID(key)) 

142 if role: 

143 return role 

144 except ValueError: 

145 pass 

146 

147 # try to match on Role.name 

148 role = session.query(model.Role)\ 

149 .filter_by(name=key)\ 

150 .first() 

151 if role: 

152 return role 

153 

154 # try settings; if value then recurse 

155 key = self.config.get(f'{self.appname}.role.{key}', 

156 session=session) 

157 if key: 

158 return self.get_role(session, key) 

159 

160 def get_user(self, obj, session=None, **kwargs): 

161 """ 

162 Return the :class:`~wuttjamaican.db.model.auth.User` 

163 associated with the given object, if one can be found. 

164 

165 This method should accept "any" type of ``obj`` and inspect it 

166 to determine if/how a user can be found. It should return the 

167 "first, most obvious" user in the event that the given object 

168 is associated with multiple users. 

169 

170 For instance ``obj`` may be a string in which case a lookup 

171 may be tried on 

172 :attr:`~wuttjamaican.db.model.auth.User.username`. Or it may 

173 be a :class:`~wuttjamaican.db.model.base.Person` in which case 

174 their :attr:`~wuttjamaican.db.model.base.Person.user` may be 

175 returned. 

176 

177 :param obj: Object for which user should be returned. 

178 

179 :param session: Open :term:`db session`. This is optional in 

180 some cases, i.e. one can be determined automatically if 

181 ``obj`` is some kind of object already contained in a 

182 session (e.g. ``Person``). But a ``session`` must be 

183 provided if ``obj`` is a simple string and you need to do a 

184 lookup by username etc. 

185 

186 :returns: :class:`~wuttjamaican.db.model.auth.User` or ``None``. 

187 """ 

188 model = self.app.model 

189 

190 # maybe obj is already a user 

191 if isinstance(obj, model.User): 

192 return obj 

193 

194 # nb. these lookups require a db session 

195 if session: 

196 

197 # or maybe it is a uuid 

198 if isinstance(obj, _uuid.UUID): 

199 user = session.get(model.User, obj) 

200 if user: 

201 return user 

202 

203 # or maybe it is a string 

204 elif isinstance(obj, str): 

205 

206 # try to match on User.uuid 

207 try: 

208 user = session.get(model.User, _uuid.UUID(obj)) 

209 if user: 

210 return user 

211 except ValueError: 

212 pass 

213 

214 # try to match on User.username 

215 user = session.query(model.User)\ 

216 .filter(model.User.username == obj)\ 

217 .first() 

218 if user: 

219 return user 

220 

221 # nb. obj is presumbly another type of object, e.g. Person 

222 

223 # maybe we can find a person, then get user 

224 person = self.app.get_person(obj) 

225 if person: 

226 return person.user 

227 

228 def make_person(self, **kwargs): 

229 """ 

230 Make and return a new 

231 :class:`~wuttjamaican.db.model.base.Person`. 

232 

233 This is a convenience wrapper around 

234 :class:`~wuttjamaican.people.PeopleHandler.make_person()`. 

235 """ 

236 people = self.app.get_people_handler() 

237 return people.make_person(**kwargs) 

238 

239 def make_user(self, session=None, **kwargs): 

240 """ 

241 Make and return a new 

242 :class:`~wuttjamaican.db.model.auth.User`. 

243 

244 This is mostly a simple wrapper around the 

245 :class:`~wuttjamaican.db.model.auth.User` constructor. All 

246 ``kwargs`` are passed on to the constructor as-is, for 

247 instance. It also will add the user to the session, if 

248 applicable. 

249 

250 This method also adds one other convenience: 

251 

252 If there is no ``username`` specified in the ``kwargs`` then 

253 it will call :meth:`make_unique_username()` to automatically 

254 provide one. (Note that the ``kwargs`` will be passed along 

255 to that call as well.) 

256 

257 :param session: Open :term:`db session`, if applicable. 

258 

259 :returns: The new :class:`~wuttjamaican.db.model.auth.User` 

260 instance. 

261 """ 

262 model = self.app.model 

263 

264 if session and 'username' not in kwargs: 

265 kwargs['username'] = self.make_unique_username(session, **kwargs) 

266 

267 user = model.User(**kwargs) 

268 if session: 

269 session.add(user) 

270 return user 

271 

272 def delete_user(self, user, **kwargs): 

273 """ 

274 Delete the given user account. Use with caution! As this 

275 generally cannot be undone. 

276 

277 Default behavior simply deletes the user account. Depending 

278 on the DB schema and data present, this may cause an error 

279 (i.e. if the user is still referenced by other tables). 

280 

281 :param user: :class:`~wuttjamaican.db.model.auth.User` to 

282 delete. 

283 """ 

284 session = self.app.get_session(user) 

285 session.delete(user) 

286 

287 def make_preferred_username(self, session, **kwargs): 

288 """ 

289 Generate a "preferred" username, using data from ``kwargs`` as 

290 hints. 

291 

292 Note that ``kwargs`` should be of the same sort that might be 

293 passed to the :class:`~wuttjamaican.db.model.auth.User` 

294 constructor. 

295 

296 So far this logic is rather simple: 

297 

298 If ``kwargs`` contains ``person`` then a username will be 

299 constructed using the name data from the person 

300 (e.g. ``'john.doe'``). 

301 

302 In all other cases it will return ``'newuser'``. 

303 

304 .. note:: 

305 

306 This method does not confirm if the username it generates 

307 is actually "available" for a new user. See 

308 :meth:`make_unique_username()` for that. 

309 

310 :param session: Open :term:`db session`. 

311 

312 :returns: Generated username as string. 

313 """ 

314 person = kwargs.get('person') 

315 if person: 

316 first = (person.first_name or '').strip().lower() 

317 last = (person.last_name or '').strip().lower() 

318 if first and last: 

319 return f'{first}.{last}' 

320 if first: 

321 return first 

322 if last: 

323 return last 

324 

325 return 'newuser' 

326 

327 def make_unique_username(self, session, **kwargs): 

328 """ 

329 Generate a *unique* username, using data from ``kwargs`` as 

330 hints. 

331 

332 Note that ``kwargs`` should be of the same sort that might be 

333 passed to the :class:`~wuttjamaican.db.model.auth.User` 

334 constructor. 

335 

336 This method is a convenience which does two things: 

337 

338 First it calls :meth:`make_preferred_username()` to obtain the 

339 "preferred" username. (It passes all ``kwargs`` along when it 

340 makes that call.) 

341 

342 Then it checks to see if the resulting username is already 

343 taken. If it is, then a "counter" is appended to the 

344 username, and incremented until a username can be found which 

345 is *not* yet taken. 

346 

347 It returns the first "available" (hence unique) username which 

348 is found. Note that it is considered unique and therefore 

349 available *at the time*; however this method does not 

350 "reserve" the username in any way. It is assumed that you 

351 would create the user yourself once you have the username. 

352 

353 :param session: Open :term:`db session`. 

354 

355 :returns: Username as string. 

356 """ 

357 model = self.app.model 

358 

359 original_username = self.make_preferred_username(session, **kwargs) 

360 username = original_username 

361 

362 # check for unique username 

363 counter = 1 

364 while True: 

365 users = session.query(model.User)\ 

366 .filter(model.User.username == username)\ 

367 .count() 

368 if not users: 

369 break 

370 username = f"{original_username}{counter:02d}" 

371 counter += 1 

372 

373 return username 

374 

375 def set_user_password(self, user, password, **kwargs): 

376 """ 

377 Set a user's password. 

378 

379 This will update the 

380 :attr:`~wuttjamaican.db.model.auth.User.password` attribute 

381 for the user. The value will be hashed using ``bcrypt``. 

382 

383 :param user: :class:`~wuttjamaican.db.model.auth.User` instance. 

384 

385 :param password: New password in plain text. 

386 """ 

387 user.password = password_context.hash(password) 

388 

389 def get_role_administrator(self, session, **kwargs): 

390 """ 

391 Returns the special "Administrator" role. 

392 """ 

393 return self._special_role(session, _uuid.UUID('d937fa8a965611dfa0dd001143047286'), 

394 "Administrator") 

395 

396 def get_role_anonymous(self, session, **kwargs): 

397 """ 

398 Returns the special "Anonymous" (aka. "Guest") role. 

399 """ 

400 return self._special_role(session, _uuid.UUID('f8a27c98965a11dfaff7001143047286'), 

401 "Anonymous") 

402 

403 def get_role_authenticated(self, session, **kwargs): 

404 """ 

405 Returns the special "Authenticated" role. 

406 """ 

407 return self._special_role(session, _uuid.UUID('b765a9cc331a11e6ac2a3ca9f40bc550'), 

408 "Authenticated") 

409 

410 def user_is_admin(self, user, **kwargs): 

411 """ 

412 Check if given user is a member of the "Administrator" role. 

413 

414 :rtype: bool 

415 """ 

416 if user: 

417 session = self.app.get_session(user) 

418 admin = self.get_role_administrator(session) 

419 if admin in user.roles: 

420 return True 

421 

422 return False 

423 

424 def get_permissions(self, session, principal, 

425 include_anonymous=True, 

426 include_authenticated=True): 

427 """ 

428 Return a set of permission names, which represents all 

429 permissions effectively granted to the given user or role. 

430 

431 :param session: Open :term:`db session`. 

432 

433 :param principal: :class:`~wuttjamaican.db.model.auth.User` or 

434 :class:`~wuttjamaican.db.model.auth.Role` instance. Can 

435 also be ``None``, in which case the "Anonymous" role will 

436 be assumed. 

437 

438 :param include_anonymous: Whether the "Anonymous" role should 

439 be included when checking permissions. If ``False``, the 

440 Anonymous permissions will *not* be checked. 

441 

442 :param include_authenticated: Whether the "Authenticated" role 

443 should be included when checking permissions. 

444 

445 :returns: Set of permission names. 

446 :rtype: set 

447 """ 

448 # we will use any `roles` attribute which may be present. in 

449 # practice we would be assuming a User in this case 

450 if hasattr(principal, 'roles'): 

451 roles = [role 

452 for role in principal.roles 

453 if self._role_is_pertinent(role)] 

454 

455 # here our User assumption gets a little more explicit 

456 if include_authenticated: 

457 roles.append(self.get_role_authenticated(session)) 

458 

459 # otherwise a non-null principal is assumed to be a Role 

460 elif principal is not None: 

461 roles = [principal] 

462 

463 # fallback assumption is "no roles" 

464 else: 

465 roles = [] 

466 

467 # maybe include anonymous role 

468 if include_anonymous: 

469 roles.append(self.get_role_anonymous(session)) 

470 

471 # build the permissions cache 

472 cache = set() 

473 for role in roles: 

474 if hasattr(role, 'permissions'): 

475 cache.update(role.permissions) 

476 

477 return cache 

478 

479 def has_permission(self, session, principal, permission, 

480 include_anonymous=True, 

481 include_authenticated=True): 

482 """ 

483 Check if the given user or role has been granted the given 

484 permission. 

485 

486 .. note:: 

487 

488 While this method is perfectly usable, it is a bit "heavy" 

489 if you need to make multiple permission checks for the same 

490 user. To optimize, call :meth:`get_permissions()` and keep 

491 the result, then instead of calling ``has_permission()`` 

492 just check if a given permission is contained in the cached 

493 result set. 

494 

495 (The logic just described is exactly what this method does, 

496 except it will not keep the result set, hence calling it 

497 multiple times for same user is not optimal.) 

498 

499 :param session: Open :term:`db session`. 

500 

501 :param principal: Either a 

502 :class:`~wuttjamaican.db.model.auth.User` or 

503 :class:`~wuttjamaican.db.model.auth.Role` instance. It is 

504 also expected that this may sometimes be ``None``, in which 

505 case the "Anonymous" role will be assumed. 

506 

507 :param permission: Name of the permission for which to check. 

508 

509 :param include_anonymous: Whether the "Anonymous" role should 

510 be included when checking permissions. If ``False``, then 

511 Anonymous permissions will *not* be checked. 

512 

513 :param include_authenticated: Whether the "Authenticated" role 

514 should be included when checking permissions. 

515 

516 :returns: Boolean indicating if the permission is granted. 

517 """ 

518 perms = self.get_permissions(session, principal, 

519 include_anonymous=include_anonymous, 

520 include_authenticated=include_authenticated) 

521 return permission in perms 

522 

523 def grant_permission(self, role, permission, **kwargs): 

524 """ 

525 Grant a permission to the role. If the role already has the 

526 permission, nothing is done. 

527 

528 :param role: :class:`~wuttjamaican.db.model.auth.Role` 

529 instance. 

530 

531 :param permission: Name of the permission as string. 

532 """ 

533 if permission not in role.permissions: 

534 role.permissions.append(permission) 

535 

536 def revoke_permission(self, role, permission, **kwargs): 

537 """ 

538 Revoke a permission from the role. If the role does not have 

539 the permission, nothing is done. 

540 

541 :param role: A :class:`~rattail.db.model.users.Role` instance. 

542 

543 :param permission: Name of the permission as string. 

544 """ 

545 if permission in role.permissions: 

546 role.permissions.remove(permission) 

547 

548 ############################## 

549 # internal methods 

550 ############################## 

551 

552 def _role_is_pertinent(self, role): 

553 """ 

554 Check the role to ensure it is "pertinent" for the current app. 

555 

556 The idea behind this is for sake of a multi-node system, where 

557 users and roles are synced between nodes. Some roles may be 

558 defined for only certain types of nodes and hence not 

559 "pertinent" for all nodes. 

560 

561 As of now there is no actual support for that, but this stub 

562 method exists for when it will. 

563 """ 

564 return True 

565 

566 def _special_role(self, session, uuid, name): 

567 """ 

568 Fetch a "special" role, creating if needed. 

569 """ 

570 model = self.app.model 

571 role = session.get(model.Role, uuid) 

572 if not role: 

573 role = model.Role(uuid=uuid, name=name) 

574 session.add(role) 

575 return role