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

139 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-12-08 00:12 -0600

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_user(self, session=None, **kwargs): 

229 """ 

230 Make and return a new 

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

232 

233 This is mostly a simple wrapper around the 

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

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

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

237 applicable. 

238 

239 This method also adds one other convenience: 

240 

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

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

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

244 to that call as well.) 

245 

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

247 

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

249 instance. 

250 """ 

251 model = self.app.model 

252 

253 if session and 'username' not in kwargs: 

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

255 

256 user = model.User(**kwargs) 

257 if session: 

258 session.add(user) 

259 return user 

260 

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

262 """ 

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

264 generally cannot be undone. 

265 

266 Default behavior simply deletes the user account. Depending 

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

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

269 

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

271 delete. 

272 """ 

273 session = self.app.get_session(user) 

274 session.delete(user) 

275 

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

277 """ 

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

279 hints. 

280 

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

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

283 constructor. 

284 

285 So far this logic is rather simple: 

286 

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

288 constructed using the name data from the person 

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

290 

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

292 

293 .. note:: 

294 

295 This method does not confirm if the username it generates 

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

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

298 

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

300 

301 :returns: Generated username as string. 

302 """ 

303 person = kwargs.get('person') 

304 if person: 

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

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

307 if first and last: 

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

309 if first: 

310 return first 

311 if last: 

312 return last 

313 

314 return 'newuser' 

315 

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

317 """ 

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

319 hints. 

320 

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

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

323 constructor. 

324 

325 This method is a convenience which does two things: 

326 

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

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

329 makes that call.) 

330 

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

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

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

334 is *not* yet taken. 

335 

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

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

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

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

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

341 

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

343 

344 :returns: Username as string. 

345 """ 

346 model = self.app.model 

347 

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

349 username = original_username 

350 

351 # check for unique username 

352 counter = 1 

353 while True: 

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

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

356 .count() 

357 if not users: 

358 break 

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

360 counter += 1 

361 

362 return username 

363 

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

365 """ 

366 Set a user's password. 

367 

368 This will update the 

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

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

371 

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

373 

374 :param password: New password in plain text. 

375 """ 

376 user.password = password_context.hash(password) 

377 

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

379 """ 

380 Returns the special "Administrator" role. 

381 """ 

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

383 "Administrator") 

384 

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

386 """ 

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

388 """ 

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

390 "Anonymous") 

391 

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

393 """ 

394 Returns the special "Authenticated" role. 

395 """ 

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

397 "Authenticated") 

398 

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

400 """ 

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

402 

403 :rtype: bool 

404 """ 

405 if user: 

406 session = self.app.get_session(user) 

407 admin = self.get_role_administrator(session) 

408 if admin in user.roles: 

409 return True 

410 

411 return False 

412 

413 def get_permissions(self, session, principal, 

414 include_anonymous=True, 

415 include_authenticated=True): 

416 """ 

417 Return a set of permission names, which represents all 

418 permissions effectively granted to the given user or role. 

419 

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

421 

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

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

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

425 be assumed. 

426 

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

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

429 Anonymous permissions will *not* be checked. 

430 

431 :param include_authenticated: Whether the "Authenticated" role 

432 should be included when checking permissions. 

433 

434 :returns: Set of permission names. 

435 :rtype: set 

436 """ 

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

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

439 if hasattr(principal, 'roles'): 

440 roles = [role 

441 for role in principal.roles 

442 if self._role_is_pertinent(role)] 

443 

444 # here our User assumption gets a little more explicit 

445 if include_authenticated: 

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

447 

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

449 elif principal is not None: 

450 roles = [principal] 

451 

452 # fallback assumption is "no roles" 

453 else: 

454 roles = [] 

455 

456 # maybe include anonymous role 

457 if include_anonymous: 

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

459 

460 # build the permissions cache 

461 cache = set() 

462 for role in roles: 

463 if hasattr(role, 'permissions'): 

464 cache.update(role.permissions) 

465 

466 return cache 

467 

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

469 include_anonymous=True, 

470 include_authenticated=True): 

471 """ 

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

473 permission. 

474 

475 .. note:: 

476 

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

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

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

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

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

482 result set. 

483 

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

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

486 multiple times for same user is not optimal.) 

487 

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

489 

490 :param principal: Either a 

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

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

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

494 case the "Anonymous" role will be assumed. 

495 

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

497 

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

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

500 Anonymous permissions will *not* be checked. 

501 

502 :param include_authenticated: Whether the "Authenticated" role 

503 should be included when checking permissions. 

504 

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

506 """ 

507 perms = self.get_permissions(session, principal, 

508 include_anonymous=include_anonymous, 

509 include_authenticated=include_authenticated) 

510 return permission in perms 

511 

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

513 """ 

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

515 permission, nothing is done. 

516 

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

518 instance. 

519 

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

521 """ 

522 if permission not in role.permissions: 

523 role.permissions.append(permission) 

524 

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

526 """ 

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

528 the permission, nothing is done. 

529 

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

531 

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

533 """ 

534 if permission in role.permissions: 

535 role.permissions.remove(permission) 

536 

537 ############################## 

538 # internal methods 

539 ############################## 

540 

541 def _role_is_pertinent(self, role): 

542 """ 

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

544 

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

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

547 defined for only certain types of nodes and hence not 

548 "pertinent" for all nodes. 

549 

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

551 method exists for when it will. 

552 """ 

553 return True 

554 

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

556 """ 

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

558 """ 

559 model = self.app.model 

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

561 if not role: 

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

563 session.add(role) 

564 return role