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

123 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2024-08-05 15: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 

29from wuttjamaican.app import GenericHandler 

30 

31 

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

33try: 

34 from passlib.context import CryptContext 

35except ImportError: # pragma: no cover 

36 pass 

37else: 

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

39 

40 

41 

42class AuthHandler(GenericHandler): 

43 """ 

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

45 handler`. 

46 

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

48 instance: 

49 

50 * authenticate user from login credentials 

51 * check which permissions a user/role has 

52 * create/modify users, roles 

53 * grant/revoke role permissions 

54 """ 

55 

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

57 """ 

58 Authenticate the given user credentials, and if successful, 

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

60 

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

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

63 

64 Custom handlers can authenticate against anything else, using 

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

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

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

68 

69 Generally speaking the credentials will have come directly 

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

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

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

73 

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

75 

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

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

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

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

80 correct etc.) 

81 

82 :param password: Password as string. 

83 

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

85 or ``None``. 

86 """ 

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

88 if user and user.active and user.password: 

89 if self.check_user_password(user, password): 

90 return user 

91 

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

93 """ 

94 Check a user's password. 

95 

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

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

98 

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

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

101 this method will determine if it was correct. 

102 

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

104 

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

106 

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

108 """ 

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

110 

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

112 """ 

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

114 per the given key, if possible. 

115 

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

117 

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

119 a UUID or name of a role. 

120 

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

122 or ``None``. 

123 """ 

124 model = self.app.model 

125 

126 if not key: 

127 return 

128 

129 # try to match on Role.uuid 

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

131 if role: 

132 return role 

133 

134 # try to match on Role.name 

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

136 .filter_by(name=key)\ 

137 .first() 

138 if role: 

139 return role 

140 

141 # try settings; if value then recurse 

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

143 session=session) 

144 if key: 

145 return self.get_role(session, key) 

146 

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

148 """ 

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

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

151 

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

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

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

155 is associated with multiple users. 

156 

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

158 may be tried on 

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

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

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

162 returned. 

163 

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

165 

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

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

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

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

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

171 lookup by username etc. 

172 

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

174 """ 

175 model = self.app.model 

176 

177 # maybe obj is already a user 

178 if isinstance(obj, model.User): 

179 return obj 

180 

181 # or maybe it is a string 

182 # (nb. these lookups require a db session) 

183 if isinstance(obj, str) and session: 

184 

185 # try to match on User.uuid 

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

187 if user: 

188 return user 

189 

190 # try to match on User.username 

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

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

193 .first() 

194 if user: 

195 return user 

196 

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

198 

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

200 person = self.app.get_person(obj) 

201 if person: 

202 return person.user 

203 

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

205 """ 

206 Make and return a new 

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

208 

209 This is mostly a simple wrapper around the 

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

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

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

213 applicable. 

214 

215 This method also adds one other convenience: 

216 

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

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

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

220 to that call as well.) 

221 

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

223 

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

225 instance. 

226 """ 

227 model = self.app.model 

228 

229 if session and 'username' not in kwargs: 

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

231 

232 user = model.User(**kwargs) 

233 if session: 

234 session.add(user) 

235 return user 

236 

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

238 """ 

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

240 generally cannot be undone. 

241 

242 Default behavior simply deletes the user account. Depending 

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

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

245 

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

247 delete. 

248 """ 

249 session = self.app.get_session(user) 

250 session.delete(user) 

251 

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

253 """ 

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

255 hints. 

256 

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

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

259 constructor. 

260 

261 So far this logic is rather simple: 

262 

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

264 constructed using the name data from the person 

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

266 

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

268 

269 .. note:: 

270 

271 This method does not confirm if the username it generates 

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

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

274 

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

276 

277 :returns: Generated username as string. 

278 """ 

279 person = kwargs.get('person') 

280 if person: 

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

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

283 if first and last: 

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

285 if first: 

286 return first 

287 if last: 

288 return last 

289 

290 return 'newuser' 

291 

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

293 """ 

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

295 hints. 

296 

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

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

299 constructor. 

300 

301 This method is a convenience which does two things: 

302 

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

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

305 makes that call.) 

306 

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

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

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

310 is *not* yet taken. 

311 

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

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

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

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

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

317 

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

319 

320 :returns: Username as string. 

321 """ 

322 model = self.app.model 

323 

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

325 username = original_username 

326 

327 # check for unique username 

328 counter = 1 

329 while True: 

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

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

332 .count() 

333 if not users: 

334 break 

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

336 counter += 1 

337 

338 return username 

339 

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

341 """ 

342 Set a user's password. 

343 

344 This will update the 

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

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

347 

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

349 

350 :param password: New password in plain text. 

351 """ 

352 user.password = password_context.hash(password) 

353 

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

355 """ 

356 Returns the special "Administrator" role. 

357 """ 

358 return self._special_role(session, 'd937fa8a965611dfa0dd001143047286', "Administrator") 

359 

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

361 """ 

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

363 """ 

364 return self._special_role(session, 'f8a27c98965a11dfaff7001143047286', "Anonymous") 

365 

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

367 """ 

368 Returns the special "Authenticated" role. 

369 """ 

370 return self._special_role(session, 'b765a9cc331a11e6ac2a3ca9f40bc550', "Authenticated") 

371 

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

373 """ 

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

375 

376 :rtype: bool 

377 """ 

378 if user: 

379 session = self.app.get_session(user) 

380 admin = self.get_role_administrator(session) 

381 if admin in user.roles: 

382 return True 

383 

384 return False 

385 

386 def get_permissions(self, session, principal, 

387 include_anonymous=True, 

388 include_authenticated=True): 

389 """ 

390 Return a set of permission names, which represents all 

391 permissions effectively granted to the given user or role. 

392 

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

394 

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

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

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

398 be assumed. 

399 

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

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

402 Anonymous permissions will *not* be checked. 

403 

404 :param include_authenticated: Whether the "Authenticated" role 

405 should be included when checking permissions. 

406 

407 :returns: Set of permission names. 

408 :rtype: set 

409 """ 

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

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

412 if hasattr(principal, 'roles'): 

413 roles = [role 

414 for role in principal.roles 

415 if self._role_is_pertinent(role)] 

416 

417 # here our User assumption gets a little more explicit 

418 if include_authenticated: 

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

420 

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

422 elif principal is not None: 

423 roles = [principal] 

424 

425 # fallback assumption is "no roles" 

426 else: 

427 roles = [] 

428 

429 # maybe include anonymous role 

430 if include_anonymous: 

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

432 

433 # build the permissions cache 

434 cache = set() 

435 for role in roles: 

436 if hasattr(role, 'permissions'): 

437 cache.update(role.permissions) 

438 

439 return cache 

440 

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

442 include_anonymous=True, 

443 include_authenticated=True): 

444 """ 

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

446 permission. 

447 

448 .. note:: 

449 

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

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

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

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

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

455 result set. 

456 

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

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

459 multiple times for same user is not optimal.) 

460 

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

462 

463 :param principal: Either a 

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

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

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

467 case the "Anonymous" role will be assumed. 

468 

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

470 

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

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

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: Boolean indicating if the permission is granted. 

479 """ 

480 perms = self.get_permissions(session, principal, 

481 include_anonymous=include_anonymous, 

482 include_authenticated=include_authenticated) 

483 return permission in perms 

484 

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

486 """ 

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

488 permission, nothing is done. 

489 

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

491 instance. 

492 

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

494 """ 

495 if permission not in role.permissions: 

496 role.permissions.append(permission) 

497 

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

499 """ 

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

501 the permission, nothing is done. 

502 

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

504 

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

506 """ 

507 if permission in role.permissions: 

508 role.permissions.remove(permission) 

509 

510 ############################## 

511 # internal methods 

512 ############################## 

513 

514 def _role_is_pertinent(self, role): 

515 """ 

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

517 

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

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

520 defined for only certain types of nodes and hence not 

521 "pertinent" for all nodes. 

522 

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

524 method exists for when it will. 

525 """ 

526 return True 

527 

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

529 """ 

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

531 """ 

532 model = self.app.model 

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

534 if not role: 

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

536 session.add(role) 

537 return role