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

168 statements  

« prev     ^ index     » next       coverage.py v7.16.1, created at 2026-10-07 21:12 -0500

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2026 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""" 

26 

27import secrets 

28import warnings 

29import uuid as _uuid 

30 

31import bcrypt 

32 

33from wuttjamaican.app import GenericHandler 

34 

35 

36class AuthHandler(GenericHandler): # pylint: disable=too-many-public-methods 

37 """ 

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

39 handler`. 

40 """ 

41 

42 def authenticate_user(self, session, username, password): 

43 """ 

44 Authenticate the given user credentials, and if successful, 

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

46 

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

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

49 

50 Custom handlers can authenticate against anything else, using 

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

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

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

54 

55 Generally speaking the credentials will have come directly 

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

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

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

59 

60 See also :meth:`authenticate_user_token()`. 

61 

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

63 

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

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

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

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

68 correct etc.) 

69 

70 :param password: Password as string. 

71 

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

73 or ``None``. 

74 """ 

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

76 if user and user.active and user.password: 

77 if self.check_user_password(user, password): 

78 return user 

79 return None 

80 

81 def authenticate_user_token(self, session, token): 

82 """ 

83 Authenticate the given user API token string, and if valid, 

84 return the corresponding user. 

85 

86 See also :meth:`authenticate_user()`. 

87 

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

89 

90 :param token: Raw token string for the user. 

91 

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

93 or ``None``. 

94 """ 

95 from sqlalchemy import orm # pylint: disable=import-outside-toplevel 

96 

97 model = self.app.model 

98 

99 try: 

100 token = ( 

101 session.query(model.UserAPIToken) 

102 .filter(model.UserAPIToken.token_string == token) 

103 .one() 

104 ) 

105 except orm.exc.NoResultFound: 

106 pass 

107 else: 

108 user = token.user 

109 if user.active: 

110 return user 

111 return None 

112 

113 def check_user_password(self, user, password): 

114 """ 

115 Check a user's password. 

116 

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

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

119 

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

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

122 this method will determine if it was correct. 

123 

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

125 

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

127 

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

129 """ 

130 return bcrypt.checkpw(password.encode("utf-8"), user.password.encode("utf-8")) 

131 

132 def get_role(self, session, key): 

133 """ 

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

135 per the given key, if possible. 

136 

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

138 

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

140 a UUID or name of a role. 

141 

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

143 or ``None``. 

144 """ 

145 model = self.app.model 

146 

147 if not key: 

148 return None 

149 

150 # maybe it is a uuid 

151 if isinstance(key, _uuid.UUID): 

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

153 if role: 

154 return role 

155 

156 else: # assuming it is a string 

157 # try to match on Role.uuid 

158 try: 

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

160 if role: 

161 return role 

162 except ValueError: 

163 pass 

164 

165 # try to match on Role.name 

166 role = session.query(model.Role).filter_by(name=key).first() 

167 if role: 

168 return role 

169 

170 # try settings; if value then recurse 

171 key = self.config.get(f"{self.appname}.role.{key}", session=session) 

172 if key: 

173 return self.get_role(session, key) 

174 return None 

175 

176 def get_user(self, obj, session=None): 

177 """ 

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

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

180 

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

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

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

184 is associated with multiple users. 

185 

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

187 may be tried on 

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

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

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

191 returned. 

192 

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

194 

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

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

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

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

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

200 lookup by username etc. 

201 

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

203 """ 

204 model = self.app.model 

205 

206 # maybe obj is already a user 

207 if isinstance(obj, model.User): 

208 return obj 

209 

210 # nb. these lookups require a db session 

211 if session: 

212 # or maybe it is a uuid 

213 if isinstance(obj, _uuid.UUID): 

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

215 if user: 

216 return user 

217 

218 # or maybe it is a string 

219 elif isinstance(obj, str): 

220 # try to match on User.uuid 

221 try: 

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

223 if user: 

224 return user 

225 except ValueError: 

226 pass 

227 

228 # try to match on User.username 

229 user = ( 

230 session.query(model.User).filter(model.User.username == obj).first() 

231 ) 

232 if user: 

233 return user 

234 

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

236 

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

238 person = self.app.get_person(obj) 

239 if person: 

240 return person.user 

241 return None 

242 

243 def make_person(self, **kwargs): 

244 """ 

245 Make and return a new 

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

247 

248 This is a convenience wrapper around 

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

250 """ 

251 people = self.app.get_people_handler() 

252 return people.make_person(**kwargs) 

253 

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

255 """ 

256 Make and return a new 

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

258 

259 This is mostly a simple wrapper around the 

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

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

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

263 applicable. 

264 

265 This method also adds one other convenience: 

266 

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

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

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

270 to that call as well.) 

271 

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

273 

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

275 instance. 

276 """ 

277 model = self.app.model 

278 

279 if session and "username" not in kwargs: 

280 kwargs["username"] = self.make_unique_username(session, **kwargs) 

281 

282 user = model.User(**kwargs) 

283 if session: 

284 session.add(user) 

285 return user 

286 

287 def delete_user(self, user): 

288 """ 

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

290 generally cannot be undone. 

291 

292 Default behavior simply deletes the user account. Depending 

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

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

295 

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

297 delete. 

298 """ 

299 session = self.app.get_session(user) 

300 session.delete(user) 

301 

302 def make_preferred_username( 

303 self, session, **kwargs 

304 ): # pylint: disable=unused-argument 

305 """ 

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

307 hints. 

308 

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

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

311 constructor. 

312 

313 So far this logic is rather simple: 

314 

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

316 constructed using the name data from the person 

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

318 

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

320 

321 .. note:: 

322 

323 This method does not confirm if the username it generates 

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

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

326 

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

328 

329 :returns: Generated username as string. 

330 """ 

331 person = kwargs.get("person") 

332 if person: 

333 first = (person.first_name or "").strip().lower() 

334 last = (person.last_name or "").strip().lower() 

335 if first and last: 

336 return f"{first}.{last}" 

337 if first: 

338 return first 

339 if last: 

340 return last 

341 

342 return "newuser" 

343 

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

345 """ 

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

347 hints. 

348 

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

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

351 constructor. 

352 

353 This method is a convenience which does two things: 

354 

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

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

357 makes that call.) 

358 

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

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

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

362 is *not* yet taken. 

363 

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

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

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

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

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

369 

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

371 

372 :returns: Username as string. 

373 """ 

374 model = self.app.model 

375 

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

377 username = original_username 

378 

379 # check for unique username 

380 counter = 1 

381 while True: 

382 users = ( 

383 session.query(model.User) 

384 .filter(model.User.username == username) 

385 .count() 

386 ) 

387 if not users: 

388 break 

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

390 counter += 1 

391 

392 return username 

393 

394 def set_user_password(self, user, password): 

395 """ 

396 Set a user's password. 

397 

398 This will update the 

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

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

401 

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

403 

404 :param password: New password in plain text. 

405 """ 

406 user.password = bcrypt.hashpw( 

407 password.encode("utf-8"), bcrypt.gensalt() 

408 ).decode("utf-8") 

409 

410 def get_role_administrator(self, session): 

411 """ 

412 Returns the special "Administrator" role. 

413 """ 

414 return self._special_role( 

415 session, _uuid.UUID("d937fa8a965611dfa0dd001143047286"), "Administrator" 

416 ) 

417 

418 def get_role_anyone(self, session): 

419 """ 

420 Returns the special "Anyone" role. 

421 """ 

422 return self._special_role( 

423 session, _uuid.UUID("f8a27c98965a11dfaff7001143047286"), "Anyone" 

424 ) 

425 

426 def get_role_anonymous( 

427 self, session 

428 ): # pragma: no cover; pylint: disable=empty-docstring 

429 """ """ 

430 warnings.warn( 

431 "AuthHandler.get_role_anonymous() is deprecated; " 

432 "please use AuthHandler.get_role_anyone() instead", 

433 DeprecationWarning, 

434 stacklevel=2, 

435 ) 

436 return self.get_role_anyone(session) 

437 

438 def get_role_authenticated(self, session): 

439 """ 

440 Returns the special "Authenticated" role. 

441 """ 

442 return self._special_role( 

443 session, _uuid.UUID("b765a9cc331a11e6ac2a3ca9f40bc550"), "Authenticated" 

444 ) 

445 

446 def user_is_admin(self, user): 

447 """ 

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

449 

450 :rtype: bool 

451 """ 

452 if user: 

453 session = self.app.get_session(user) 

454 admin = self.get_role_administrator(session) 

455 if admin in user.roles: 

456 return True 

457 

458 return False 

459 

460 def get_permissions( 

461 self, session, principal, include_anyone=True, include_authenticated=True 

462 ): 

463 """ 

464 Return a set of permission names, which represents all 

465 permissions effectively granted to the given user or role. 

466 

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

468 

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

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

471 also be ``None``, in which case the "Anyone" role will be 

472 assumed. 

473 

474 :param include_anyone: Whether the "Anyone" role should be 

475 included when checking permissions. If ``False``, the 

476 Anyone permissions will *not* be checked. 

477 

478 :param include_authenticated: Whether the "Authenticated" role 

479 should be included when checking permissions. 

480 

481 :returns: Set of permission names. 

482 :rtype: set 

483 """ 

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

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

486 if hasattr(principal, "roles"): 

487 roles = [role for role in principal.roles if self._role_is_pertinent(role)] 

488 

489 # here our User assumption gets a little more explicit 

490 if include_authenticated: 

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

492 

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

494 elif principal is not None: 

495 roles = [principal] 

496 

497 # fallback assumption is "no roles" 

498 else: 

499 roles = [] 

500 

501 # maybe include anyone role 

502 if include_anyone: 

503 roles.append(self.get_role_anyone(session)) 

504 

505 # build the permissions cache 

506 cache = set() 

507 for role in roles: 

508 if hasattr(role, "permissions"): 

509 cache.update(role.permissions) 

510 

511 return cache 

512 

513 def has_permission( # pylint: disable=too-many-arguments,too-many-positional-arguments 

514 self, 

515 session, 

516 principal, 

517 permission, 

518 include_anyone=True, 

519 include_authenticated=True, 

520 ): 

521 """ 

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

523 permission. 

524 

525 .. note:: 

526 

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

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

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

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

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

532 result set. 

533 

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

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

536 multiple times for same user is not optimal.) 

537 

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

539 

540 :param principal: Either a 

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

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

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

544 case the "Anyone" role will be assumed. 

545 

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

547 

548 :param include_anyone: Whether the "Anyone" role should be 

549 included when checking permissions. If ``False``, then 

550 Anyone permissions will *not* be checked. 

551 

552 :param include_authenticated: Whether the "Authenticated" role 

553 should be included when checking permissions. 

554 

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

556 """ 

557 perms = self.get_permissions( 

558 session, 

559 principal, 

560 include_anyone=include_anyone, 

561 include_authenticated=include_authenticated, 

562 ) 

563 return permission in perms 

564 

565 def grant_permission(self, role, permission): 

566 """ 

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

568 permission, nothing is done. 

569 

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

571 instance. 

572 

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

574 """ 

575 if permission not in role.permissions: 

576 role.permissions.append(permission) 

577 

578 def revoke_permission(self, role, permission): 

579 """ 

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

581 the permission, nothing is done. 

582 

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

584 

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

586 """ 

587 if permission in role.permissions: 

588 role.permissions.remove(permission) 

589 

590 ############################## 

591 # API token methods 

592 ############################## 

593 

594 def add_api_token(self, user, description): 

595 """ 

596 Add and return a new API token for the user. 

597 

598 This calls :meth:`generate_api_token_string()` to obtain the 

599 actual token string. 

600 

601 See also :meth:`delete_api_token()`. 

602 

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

604 instance for which to add the token. 

605 

606 :param description: String description for the token. 

607 

608 :rtype: :class:`~wuttjamaican.db.model.auth.UserAPIToken` 

609 """ 

610 model = self.app.model 

611 session = self.app.get_session(user) 

612 

613 # generate raw token 

614 token_string = self.generate_api_token_string() 

615 

616 # persist token in DB 

617 token = model.UserAPIToken(description=description, token_string=token_string) 

618 user.api_tokens.append(token) 

619 session.add(token) 

620 

621 return token 

622 

623 def generate_api_token_string(self): 

624 """ 

625 Generate a new *raw* API token string. 

626 

627 This is called by :meth:`add_api_token()`. 

628 

629 :returns: Raw API token string. 

630 """ 

631 return secrets.token_urlsafe() 

632 

633 def delete_api_token(self, token): 

634 """ 

635 Delete the given API token. 

636 

637 See also :meth:`add_api_token()`. 

638 

639 :param token: 

640 :class:`~wuttjamaican.db.model.auth.UserAPIToken` instance. 

641 """ 

642 session = self.app.get_session(token) 

643 session.delete(token) 

644 

645 ############################## 

646 # internal methods 

647 ############################## 

648 

649 def _role_is_pertinent(self, role): # pylint: disable=unused-argument 

650 """ 

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

652 

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

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

655 defined for only certain types of nodes and hence not 

656 "pertinent" for all nodes. 

657 

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

659 method exists for when it will. 

660 """ 

661 return True 

662 

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

664 """ 

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

666 """ 

667 model = self.app.model 

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

669 if not role: 

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

671 session.add(role) 

672 return role