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

167 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-19 13:14 -0500

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

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

3# 

4# WuttJamaican -- Base package for Wutta Framework 

5# Copyright © 2023-2025 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 secrets 

30import uuid as _uuid 

31 

32import bcrypt 

33 

34from wuttjamaican.app import GenericHandler 

35 

36 

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

38 """ 

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

40 handler`. 

41 

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

43 instance: 

44 

45 * authenticate user from login credentials 

46 * check which permissions a user/role has 

47 * create/modify users, roles 

48 * grant/revoke role permissions 

49 """ 

50 

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

52 """ 

53 Authenticate the given user credentials, and if successful, 

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

55 

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

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

58 

59 Custom handlers can authenticate against anything else, using 

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

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

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

63 

64 Generally speaking the credentials will have come directly 

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

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

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

68 

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

70 

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

72 

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

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

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

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

77 correct etc.) 

78 

79 :param password: Password as string. 

80 

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

82 or ``None``. 

83 """ 

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

85 if user and user.active and user.password: 

86 if self.check_user_password(user, password): 

87 return user 

88 return None 

89 

90 def authenticate_user_token(self, session, token): 

91 """ 

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

93 return the corresponding user. 

94 

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

96 

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

98 

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

100 

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

102 or ``None``. 

103 """ 

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

105 

106 model = self.app.model 

107 

108 try: 

109 token = ( 

110 session.query(model.UserAPIToken) 

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

112 .one() 

113 ) 

114 except orm.exc.NoResultFound: 

115 pass 

116 else: 

117 user = token.user 

118 if user.active: 

119 return user 

120 return None 

121 

122 def check_user_password(self, user, password): 

123 """ 

124 Check a user's password. 

125 

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

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

128 

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

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

131 this method will determine if it was correct. 

132 

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

134 

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

136 

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

138 """ 

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

140 

141 def get_role(self, session, key): 

142 """ 

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

144 per the given key, if possible. 

145 

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

147 

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

149 a UUID or name of a role. 

150 

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

152 or ``None``. 

153 """ 

154 model = self.app.model 

155 

156 if not key: 

157 return None 

158 

159 # maybe it is a uuid 

160 if isinstance(key, _uuid.UUID): 

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

162 if role: 

163 return role 

164 

165 else: # assuming it is a string 

166 # try to match on Role.uuid 

167 try: 

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

169 if role: 

170 return role 

171 except ValueError: 

172 pass 

173 

174 # try to match on Role.name 

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

176 if role: 

177 return role 

178 

179 # try settings; if value then recurse 

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

181 if key: 

182 return self.get_role(session, key) 

183 return None 

184 

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

186 """ 

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

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

189 

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

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

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

193 is associated with multiple users. 

194 

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

196 may be tried on 

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

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

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

200 returned. 

201 

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

203 

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

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

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

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

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

209 lookup by username etc. 

210 

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

212 """ 

213 model = self.app.model 

214 

215 # maybe obj is already a user 

216 if isinstance(obj, model.User): 

217 return obj 

218 

219 # nb. these lookups require a db session 

220 if session: 

221 # or maybe it is a uuid 

222 if isinstance(obj, _uuid.UUID): 

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

224 if user: 

225 return user 

226 

227 # or maybe it is a string 

228 elif isinstance(obj, str): 

229 # try to match on User.uuid 

230 try: 

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

232 if user: 

233 return user 

234 except ValueError: 

235 pass 

236 

237 # try to match on User.username 

238 user = ( 

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

240 ) 

241 if user: 

242 return user 

243 

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

245 

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

247 person = self.app.get_person(obj) 

248 if person: 

249 return person.user 

250 return None 

251 

252 def make_person(self, **kwargs): 

253 """ 

254 Make and return a new 

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

256 

257 This is a convenience wrapper around 

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

259 """ 

260 people = self.app.get_people_handler() 

261 return people.make_person(**kwargs) 

262 

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

264 """ 

265 Make and return a new 

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

267 

268 This is mostly a simple wrapper around the 

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

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

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

272 applicable. 

273 

274 This method also adds one other convenience: 

275 

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

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

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

279 to that call as well.) 

280 

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

282 

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

284 instance. 

285 """ 

286 model = self.app.model 

287 

288 if session and "username" not in kwargs: 

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

290 

291 user = model.User(**kwargs) 

292 if session: 

293 session.add(user) 

294 return user 

295 

296 def delete_user(self, user): 

297 """ 

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

299 generally cannot be undone. 

300 

301 Default behavior simply deletes the user account. Depending 

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

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

304 

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

306 delete. 

307 """ 

308 session = self.app.get_session(user) 

309 session.delete(user) 

310 

311 def make_preferred_username( 

312 self, session, **kwargs 

313 ): # pylint: disable=unused-argument 

314 """ 

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

316 hints. 

317 

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

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

320 constructor. 

321 

322 So far this logic is rather simple: 

323 

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

325 constructed using the name data from the person 

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

327 

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

329 

330 .. note:: 

331 

332 This method does not confirm if the username it generates 

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

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

335 

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

337 

338 :returns: Generated username as string. 

339 """ 

340 person = kwargs.get("person") 

341 if person: 

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

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

344 if first and last: 

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

346 if first: 

347 return first 

348 if last: 

349 return last 

350 

351 return "newuser" 

352 

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

354 """ 

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

356 hints. 

357 

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

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

360 constructor. 

361 

362 This method is a convenience which does two things: 

363 

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

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

366 makes that call.) 

367 

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

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

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

371 is *not* yet taken. 

372 

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

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

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

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

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

378 

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

380 

381 :returns: Username as string. 

382 """ 

383 model = self.app.model 

384 

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

386 username = original_username 

387 

388 # check for unique username 

389 counter = 1 

390 while True: 

391 users = ( 

392 session.query(model.User) 

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

394 .count() 

395 ) 

396 if not users: 

397 break 

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

399 counter += 1 

400 

401 return username 

402 

403 def set_user_password(self, user, password): 

404 """ 

405 Set a user's password. 

406 

407 This will update the 

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

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

410 

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

412 

413 :param password: New password in plain text. 

414 """ 

415 user.password = bcrypt.hashpw( 

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

417 ).decode("utf-8") 

418 

419 def get_role_administrator(self, session): 

420 """ 

421 Returns the special "Administrator" role. 

422 """ 

423 return self._special_role( 

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

425 ) 

426 

427 def get_role_anonymous(self, session): 

428 """ 

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

430 """ 

431 return self._special_role( 

432 session, _uuid.UUID("f8a27c98965a11dfaff7001143047286"), "Anonymous" 

433 ) 

434 

435 def get_role_authenticated(self, session): 

436 """ 

437 Returns the special "Authenticated" role. 

438 """ 

439 return self._special_role( 

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

441 ) 

442 

443 def user_is_admin(self, user): 

444 """ 

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

446 

447 :rtype: bool 

448 """ 

449 if user: 

450 session = self.app.get_session(user) 

451 admin = self.get_role_administrator(session) 

452 if admin in user.roles: 

453 return True 

454 

455 return False 

456 

457 def get_permissions( 

458 self, session, principal, include_anonymous=True, include_authenticated=True 

459 ): 

460 """ 

461 Return a set of permission names, which represents all 

462 permissions effectively granted to the given user or role. 

463 

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

465 

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

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

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

469 be assumed. 

470 

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

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

473 Anonymous permissions will *not* be checked. 

474 

475 :param include_authenticated: Whether the "Authenticated" role 

476 should be included when checking permissions. 

477 

478 :returns: Set of permission names. 

479 :rtype: set 

480 """ 

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

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

483 if hasattr(principal, "roles"): 

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

485 

486 # here our User assumption gets a little more explicit 

487 if include_authenticated: 

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

489 

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

491 elif principal is not None: 

492 roles = [principal] 

493 

494 # fallback assumption is "no roles" 

495 else: 

496 roles = [] 

497 

498 # maybe include anonymous role 

499 if include_anonymous: 

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

501 

502 # build the permissions cache 

503 cache = set() 

504 for role in roles: 

505 if hasattr(role, "permissions"): 

506 cache.update(role.permissions) 

507 

508 return cache 

509 

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

511 self, 

512 session, 

513 principal, 

514 permission, 

515 include_anonymous=True, 

516 include_authenticated=True, 

517 ): 

518 """ 

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

520 permission. 

521 

522 .. note:: 

523 

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

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

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

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

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

529 result set. 

530 

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

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

533 multiple times for same user is not optimal.) 

534 

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

536 

537 :param principal: Either a 

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

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

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

541 case the "Anonymous" role will be assumed. 

542 

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

544 

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

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

547 Anonymous permissions will *not* be checked. 

548 

549 :param include_authenticated: Whether the "Authenticated" role 

550 should be included when checking permissions. 

551 

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

553 """ 

554 perms = self.get_permissions( 

555 session, 

556 principal, 

557 include_anonymous=include_anonymous, 

558 include_authenticated=include_authenticated, 

559 ) 

560 return permission in perms 

561 

562 def grant_permission(self, role, permission): 

563 """ 

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

565 permission, nothing is done. 

566 

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

568 instance. 

569 

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

571 """ 

572 if permission not in role.permissions: 

573 role.permissions.append(permission) 

574 

575 def revoke_permission(self, role, permission): 

576 """ 

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

578 the permission, nothing is done. 

579 

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

581 

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

583 """ 

584 if permission in role.permissions: 

585 role.permissions.remove(permission) 

586 

587 ############################## 

588 # API token methods 

589 ############################## 

590 

591 def add_api_token(self, user, description): 

592 """ 

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

594 

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

596 actual token string. 

597 

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

599 

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

601 instance for which to add the token. 

602 

603 :param description: String description for the token. 

604 

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

606 """ 

607 model = self.app.model 

608 session = self.app.get_session(user) 

609 

610 # generate raw token 

611 token_string = self.generate_api_token_string() 

612 

613 # persist token in DB 

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

615 user.api_tokens.append(token) 

616 session.add(token) 

617 

618 return token 

619 

620 def generate_api_token_string(self): 

621 """ 

622 Generate a new *raw* API token string. 

623 

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

625 

626 :returns: Raw API token string. 

627 """ 

628 return secrets.token_urlsafe() 

629 

630 def delete_api_token(self, token): 

631 """ 

632 Delete the given API token. 

633 

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

635 

636 :param token: 

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

638 """ 

639 session = self.app.get_session(token) 

640 session.delete(token) 

641 

642 ############################## 

643 # internal methods 

644 ############################## 

645 

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

647 """ 

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

649 

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

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

652 defined for only certain types of nodes and hence not 

653 "pertinent" for all nodes. 

654 

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

656 method exists for when it will. 

657 """ 

658 return True 

659 

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

661 """ 

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

663 """ 

664 model = self.app.model 

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

666 if not role: 

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

668 session.add(role) 

669 return role