Coverage for gws-app/gws/plugin/account/helper.py: 0%

203 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-05 13:35 +0200

1"""Account helper.""" 

2 

3from typing import Optional, cast 

4 

5import gws 

6import gws.base.edit.helper 

7import gws.config.util 

8import gws.plugin.email_helper 

9import gws.lib.image 

10import gws.lib.net 

11import gws.lib.otp 

12 

13from . import core 

14 

15 

16class MfaConfig(gws.Config): 

17 """Multi-factor authentication method offered to users.""" 

18 

19 mfaUid: str 

20 """UID of the multi-factor authentication adapter.""" 

21 title: str 

22 """Title of the method shown to users.""" 

23 

24 

25@gws.ext.config.helper('account') 

26class Config(gws.Config): 

27 """Accounts table, onboarding and password settings, shared by account components.""" 

28 

29 adminModel: gws.ext.config.model 

30 """Model of the accounts table for account administrators.""" 

31 userModel: Optional[gws.ext.config.model] 

32 """Model for account data that users can edit themselves.""" 

33 templates: list[gws.ext.config.template] 

34 """Templates for account emails.""" 

35 

36 usernameColumn: str = 'email' 

37 """Column of the accounts table used as the login name.""" 

38 

39 passwordCreateSql: Optional[str] 

40 """SQL expression for computing password hashes.""" 

41 passwordVerifySql: Optional[str] 

42 """SQL expression for checking a password against the stored hash.""" 

43 

44 tcLifeTime: gws.Duration = '3600' 

45 """Validity period of temporary codes.""" 

46 

47 mfa: Optional[list[MfaConfig]] 

48 """Multi-factor authentication methods the user can choose from.""" 

49 mfaIssuer: str = '' 

50 """Issuer name for multi-factor key URIs (QR codes).""" 

51 

52 onboardingUrl: str 

53 """Onboarding page URL sent to new users.""" 

54 onboardingCompletionUrl: str = '' 

55 """URL to redirect to after onboarding.""" 

56 

57 

58## 

59 

60 

61class Error(gws.Error): 

62 """Account-related error.""" 

63 

64 pass 

65 

66 

67## 

68 

69 

70class MfaOption(gws.Data): 

71 """Configured MFA method.""" 

72 

73 index: int 

74 """Position in the ``mfa`` configuration list, starting with 1.""" 

75 title: str 

76 """Title shown to users.""" 

77 adapter: Optional[gws.AuthMultiFactorAdapter] 

78 """MFA adapter, ``None`` for the "no MFA" option.""" 

79 

80 

81_DEFAULT_PASSWORD_CREATE_SQL = "crypt( {password}, gen_salt('bf') )" 

82_DEFAULT_PASSWORD_VERIFY_SQL = 'crypt( {password}, {passwordColumn} )' 

83 

84 

85@gws.ext.object.helper('account') 

86class Object(gws.base.edit.helper.Object): 

87 """Account helper. 

88 

89 Holds the account configuration and implements account queries and updates in the table of ``adminModel``, 

90 temporary codes, passwords, MFA and emails. As an edit helper, it serves the edit API 

91 of the ``accountadmin`` action for ``adminModel``. 

92 """ 

93 

94 adminModel: gws.DatabaseModel 

95 """Model of the accounts table for administrators.""" 

96 userModel: gws.DatabaseModel 

97 """Model for account data that users can edit themselves, not created yet.""" 

98 templates: list[gws.Template] 

99 """Email templates.""" 

100 

101 mfaIssuer: str 

102 """Issuer name for MFA key URIs.""" 

103 mfaOptions: list[MfaOption] 

104 """Configured MFA methods.""" 

105 onboardingUrl: str 

106 """Onboarding page URL sent to new users.""" 

107 onboardingCompletionUrl: str 

108 """URL to go to after the onboarding, defaults to ``onboardingUrl``.""" 

109 passwordCreateSql: str 

110 """SQL expression that computes a password hash, with the placeholders ``{password}`` and ``{passwordColumn}``.""" 

111 passwordVerifySql: str 

112 """SQL expression that computes the hash to compare with the stored one, with the same placeholders.""" 

113 tcLifeTime: int 

114 """Validity period of temporary codes in seconds.""" 

115 usernameColumn: str 

116 """Column with the login name.""" 

117 

118 def configure(self): 

119 self.configure_templates() 

120 

121 self.adminModel = cast(gws.DatabaseModel, self.create_child(gws.ext.object.model, self.cfg('adminModel'))) 

122 

123 self.mfaIssuer = self.cfg('mfaIssuer') 

124 self.mfaOptions = [] 

125 

126 self.onboardingUrl = self.cfg('onboardingUrl') 

127 self.onboardingCompletionUrl = self.cfg('onboardingCompletionUrl') or self.onboardingUrl 

128 

129 self.passwordCreateSql = self.cfg('passwordCreateSql', default=_DEFAULT_PASSWORD_CREATE_SQL) 

130 self.passwordVerifySql = self.cfg('passwordVerifySql', default=_DEFAULT_PASSWORD_VERIFY_SQL) 

131 

132 self.tcLifeTime = self.cfg('tcLifeTime', default=3600) 

133 

134 self.usernameColumn = self.cfg('usernameColumn', default=core.Columns.email) 

135 

136 def configure_templates(self): 

137 """Configure the email templates. 

138 

139 Returns: 

140 ``True`` if templates were configured. 

141 """ 

142 

143 

144 return gws.config.util.configure_templates_for(self) 

145 

146 def post_configure(self): 

147 for n, c in enumerate(self.cfg('mfa', default=[]), 1): 

148 opt = MfaOption(index=n, title=c.title) 

149 if c.mfaUid: 

150 opt.adapter = self.root.get(c.mfaUid) 

151 if not opt.adapter: 

152 raise gws.ConfigurationError(f'MFA Adapter not found {c.mfaUid=}') 

153 self.mfaOptions.append(opt) 

154 

155 ## 

156 

157 def get_models(self, req, p): 

158 return [self.adminModel] 

159 

160 def write_feature(self, req, p): 

161 is_new = p.feature.isNew 

162 f = super().write_feature(req, p) 

163 

164 if f and not f.errors and is_new: 

165 account = self.get_account_by_id(f.uid()) 

166 self.reset(account) 

167 

168 return f 

169 

170 ## 

171 

172 def get_account_by_id(self, uid: str) -> Optional[dict]: 

173 """Find an account by its primary key. 

174 

175 Args: 

176 uid: Primary key value. 

177 

178 Returns: 

179 The account record, or ``None`` if not found. 

180 """ 

181 

182 

183 sql = f""" 

184 SELECT * FROM {self.adminModel.tableName} 

185 WHERE {self.adminModel.uidName}=:uid 

186 """ 

187 rs = self.adminModel.db.select_text(sql, uid=uid) 

188 return rs[0] if rs else None 

189 

190 def get_account_by_credentials(self, credentials: gws.Data, expected_status: Optional[core.Status] = None) -> Optional[dict]: 

191 """Find an account by login name and password. 

192 

193 Args: 

194 credentials: Credentials with ``username`` and ``password``. 

195 expected_status: If given, the account must have this status. 

196 

197 Returns: 

198 The account record, or ``None`` if the login name is not found. 

199 

200 Raises: 

201 Error: If the login name is not unique, the password is wrong, or the status is not the expected one. 

202 """ 

203 

204 

205 expr = self.passwordVerifySql 

206 expr = expr.replace('{password}', ':password') 

207 expr = expr.replace('{passwordColumn}', core.Columns.password) 

208 

209 username = credentials.get('username') 

210 password = credentials.get('password') 

211 

212 sql = f""" 

213 SELECT 

214 {self.adminModel.uidName}, 

215 ( {core.Columns.password} = {expr} ) AS validpassword, 

216 {core.Columns.status} 

217 FROM 

218 {self.adminModel.tableName} 

219 WHERE 

220 {self.usernameColumn} = :username 

221 """ 

222 rs = self.adminModel.db.select_text(sql, username=username, password=password) 

223 

224 if not rs: 

225 gws.log.warning(f'get_account_by_credentials: {username=} not found') 

226 return 

227 

228 if len(rs) > 1: 

229 raise Error(f'get_account_by_credentials: multiple entries for {username=}') 

230 

231 r = rs[0] 

232 

233 if not r.get('validpassword'): 

234 raise Error(f'get_account_by_credentials: {username=} wrong password') 

235 

236 if expected_status: 

237 status = r.get(core.Columns.status) 

238 if status != expected_status: 

239 raise Error(f'get_account_by_credentials: {username=} wrong {status=} {expected_status=}') 

240 

241 return self.get_account_by_id(self.get_uid(r)) 

242 

243 def get_account_by_tc(self, tc: str, category: str, expected_status: Optional[core.Status] = None) -> Optional[dict]: 

244 """Find an account by a temporary code. 

245 

246 If the code is found, it is invalidated, so that it can be used only once. 

247 

248 Args: 

249 tc: Temporary code. 

250 category: Expected category of the code. 

251 expected_status: If given, the account must have this status. 

252 

253 Returns: 

254 The account record, or ``None`` if the code is not found, has a different category, is expired, 

255 or the account has a different status. 

256 

257 Raises: 

258 Error: If the code is not unique. 

259 """ 

260 

261 

262 sql = f""" 

263 SELECT 

264 {self.adminModel.uidName}, 

265 {core.Columns.tcTime}, 

266 {core.Columns.tcCategory}, 

267 {core.Columns.status} 

268 FROM 

269 {self.adminModel.tableName} 

270 WHERE 

271 {core.Columns.tc} = :tc 

272 """ 

273 rs = self.adminModel.db.select_text(sql, tc=tc) 

274 

275 if not rs: 

276 gws.log.warning(f'get_account_by_tc: {tc=} not found') 

277 return 

278 

279 self.invalidate_tc(tc) 

280 

281 if len(rs) > 1: 

282 raise Error(f'get_account_by_tc: {tc=} multiple entries') 

283 

284 r = rs[0] 

285 

286 if r.get(core.Columns.tcCategory) != category: 

287 gws.log.warning(f'get_account_by_tc: {category=} {tc=} wrong category') 

288 return 

289 

290 if gws.u.stime() - r.get(core.Columns.tcTime, 0) > self.tcLifeTime: 

291 gws.log.warning(f'get_account_by_tc: {category=} {tc=} expired') 

292 return 

293 

294 if expected_status: 

295 status = r.get(core.Columns.status) 

296 if status != expected_status: 

297 gws.log.warning(f'get_account_by_tc: {category=} {tc=} wrong {status=} {expected_status=}') 

298 return 

299 

300 return self.get_account_by_id(self.get_uid(r)) 

301 

302 ## 

303 

304 def set_password(self, account: dict, password): 

305 """Store the hash of a new password. 

306 

307 Args: 

308 account: Account record. 

309 password: New password in plain text. 

310 """ 

311 

312 

313 expr = self.passwordCreateSql 

314 expr = expr.replace('{password}', ':password') 

315 expr = expr.replace('{passwordColumn}', core.Columns.password) 

316 

317 sql = f""" 

318 UPDATE {self.adminModel.tableName} 

319 SET 

320 {core.Columns.password} = {expr} 

321 WHERE 

322 {self.adminModel.uidName} = :uid 

323 """ 

324 self.adminModel.db.execute_text(sql, password=password, uid=self.get_uid(account)) 

325 

326 def validate_password(self, password: str) -> bool: 

327 """Check if a password is acceptable. 

328 

329 Currently only empty passwords are rejected. 

330 

331 Args: 

332 password: Password in plain text. 

333 

334 Returns: 

335 ``True`` if the password is acceptable. 

336 """ 

337 

338 

339 if len(password.strip()) == 0: 

340 return False 

341 # @TODO password complexity validation 

342 return True 

343 

344 ## 

345 

346 def set_mfa(self, account: dict, mfa_option_index: int): 

347 """Store the selected MFA method of an account. 

348 

349 Args: 

350 account: Account record. 

351 mfa_option_index: Index of the MFA option. 

352 

353 Raises: 

354 Error: If there is no option with this index. 

355 """ 

356 

357 

358 mfa_uid = None 

359 

360 for mo in self.mfa_options(account): 

361 if mo.index == mfa_option_index: 

362 mfa_uid = mo.adapter.uid if mo.adapter else '' 

363 break 

364 

365 if mfa_uid is None: 

366 raise Error(f'{mfa_option_index=} not found') 

367 

368 sql = f""" 

369 UPDATE {self.adminModel.tableName} 

370 SET 

371 {core.Columns.mfaUid} = :mfa_uid 

372 WHERE 

373 {self.adminModel.uidName} = :uid 

374 """ 

375 self.adminModel.db.execute_text(sql, mfa_uid=mfa_uid, uid=self.get_uid(account)) 

376 

377 def mfa_options(self, account: dict) -> list[MfaOption]: 

378 """Get the MFA methods available to an account. 

379 

380 Currently all configured methods are available to all accounts. 

381 

382 Args: 

383 account: Account record. 

384 

385 Returns: 

386 A list of MFA options. 

387 """ 

388 

389 

390 # @TODO different options per account 

391 return self.mfaOptions 

392 

393 def generate_mfa_secret(self, account: dict) -> str: 

394 """Generate and store a new MFA secret. 

395 

396 Args: 

397 account: Account record. 

398 

399 Returns: 

400 The secret. 

401 """ 

402 

403 

404 secret = gws.lib.otp.random_secret() 

405 

406 sql = f""" 

407 UPDATE {self.adminModel.tableName} 

408 SET 

409 {core.Columns.mfaSecret} = :secret 

410 WHERE 

411 {self.adminModel.uidName} = :uid 

412 """ 

413 self.adminModel.db.execute_text(sql, secret=secret, uid=self.get_uid(account)) 

414 

415 return secret 

416 

417 def qr_code_for_mfa(self, account: dict, mo: MfaOption, secret: str) -> str: 

418 """Create a QR code of the key URI for an MFA method. 

419 

420 Args: 

421 account: Account record. 

422 mo: MFA option. 

423 secret: MFA secret. 

424 

425 Returns: 

426 The QR code image as a data URL, or an empty string if the method has no adapter or no key URI. 

427 """ 

428 

429 

430 if not mo.adapter: 

431 return '' 

432 url = mo.adapter.key_uri(secret, self.mfaIssuer, account.get(self.usernameColumn)) 

433 if not url: 

434 return '' 

435 return gws.lib.image.qr_code(url).to_data_url() 

436 

437 ## 

438 

439 def set_status(self, account: dict, status: core.Status): 

440 """Set the status of an account. 

441 

442 Args: 

443 account: Account record. 

444 status: New status. 

445 """ 

446 

447 

448 sql = f""" 

449 UPDATE {self.adminModel.tableName} 

450 SET 

451 {core.Columns.status} = :status 

452 WHERE 

453 {self.adminModel.uidName} = :uid 

454 """ 

455 self.adminModel.db.execute_text(sql, status=status, uid=self.get_uid(account)) 

456 

457 def reset(self, account: dict): 

458 """Reset an account. 

459 

460 Clears the password and the MFA secret and sets the status to ``new``. 

461 If ``onboardingUrl`` is configured, the onboarding email is sent. 

462 

463 Args: 

464 account: Account record. 

465 

466 Raises: 

467 Error: If the onboarding email is due and the account has no email address. 

468 """ 

469 

470 

471 sql = f""" 

472 UPDATE {self.adminModel.tableName} 

473 SET 

474 {core.Columns.status} = :status, 

475 {core.Columns.password} = '', 

476 {core.Columns.mfaSecret} = '' 

477 WHERE 

478 {self.adminModel.uidName} = :uid 

479 """ 

480 self.adminModel.db.execute_text(sql, status=core.Status.new, uid=self.get_uid(account)) 

481 

482 if self.onboardingUrl: 

483 self.send_onboarding_email(account) 

484 

485 ## 

486 

487 def send_onboarding_email(self, account: dict): 

488 """Generate an onboarding code and send the onboarding email with the link. 

489 

490 Args: 

491 account: Account record. 

492 

493 Raises: 

494 Error: If the account has no email address. 

495 """ 

496 

497 

498 tc = self.generate_tc(account, core.Category.onboarding) 

499 url = gws.lib.net.add_params(self.onboardingUrl, onboarding=tc) 

500 self.send_mail(account, core.Category.onboarding, {'url': url}) 

501 

502 def generate_tc(self, account: dict, category: str) -> str: 

503 """Generate and store a new temporary code. 

504 

505 Args: 

506 account: Account record. 

507 category: Code category. 

508 

509 Returns: 

510 The code. 

511 """ 

512 

513 

514 tc = self.make_tc() 

515 

516 sql = f""" 

517 UPDATE {self.adminModel.tableName} 

518 SET 

519 {core.Columns.tc} = :tc, 

520 {core.Columns.tcTime} = :time, 

521 {core.Columns.tcCategory} = :category 

522 WHERE 

523 {self.adminModel.uidName} = :uid 

524 """ 

525 self.adminModel.db.execute_text(sql, tc=tc, time=gws.u.stime(), category=category, uid=self.get_uid(account)) 

526 

527 return tc 

528 

529 def clear_tc(self, account: dict): 

530 """Remove the temporary code of an account. 

531 

532 Args: 

533 account: Account record. 

534 """ 

535 

536 

537 sql = f""" 

538 UPDATE {self.adminModel.tableName} 

539 SET 

540 {core.Columns.tc} = '', 

541 {core.Columns.tcTime} = 0, 

542 {core.Columns.tcCategory} = '' 

543 WHERE 

544 {self.adminModel.uidName} = :uid 

545 """ 

546 self.adminModel.db.execute_text(sql, uid=self.get_uid(account)) 

547 

548 def invalidate_tc(self, tc: str): 

549 """Remove a temporary code from all accounts that have it. 

550 

551 Args: 

552 tc: Temporary code. 

553 """ 

554 

555 

556 sql = f""" 

557 UPDATE {self.adminModel.tableName} 

558 SET 

559 {core.Columns.tc} = '', 

560 {core.Columns.tcTime} = 0, 

561 {core.Columns.tcCategory} = '' 

562 WHERE 

563 {core.Columns.tc} = :tc 

564 """ 

565 self.adminModel.db.execute_text(sql, tc=tc) 

566 

567 ## 

568 

569 def get_uid(self, account: dict) -> str: 

570 """Get the primary key of an account. 

571 

572 Args: 

573 account: Account record. 

574 

575 Returns: 

576 The primary key value. 

577 """ 

578 

579 

580 return account.get(self.adminModel.uidName) 

581 

582 def make_tc(self): 

583 """Create a random temporary code. 

584 

585 Returns: 

586 A random string of 32 characters. 

587 """ 

588 

589 

590 return gws.u.random_string(32) 

591 

592 def send_mail(self, account: dict, category: str, args: Optional[dict] = None): 

593 """Send an email to an account. 

594 

595 The subject and the body are rendered from the templates ``<category>.emailSubject`` and 

596 ``<category>.emailBody`` (plain text and HTML), and sent with the ``email`` helper. 

597 

598 Args: 

599 account: Account record. 

600 category: Email category, see ``core.Category``. 

601 args: Template arguments, the account record is added as ``account``. 

602 

603 Raises: 

604 Error: If the account has no email address. 

605 """ 

606 

607 

608 email = account.get(core.Columns.email) 

609 if not email: 

610 raise Error(f'account {self.get_uid(account)}: no email') 

611 

612 args = args or {} 

613 args['account'] = account 

614 

615 message = gws.plugin.email_helper.Message( 

616 subject=self.render_template(f'{category}.emailSubject', args), 

617 mailTo=email, 

618 text=self.render_template(f'{category}.emailBody', args, mime_type='text/plain'), 

619 html=self.render_template(f'{category}.emailBody', args, mime_type='text/html'), 

620 ) 

621 

622 email_helper = cast(gws.plugin.email_helper.Object, self.root.app.helper('email')) 

623 email_helper.send_mail(message) 

624 

625 def render_template(self, subject, args, mime_type=None): 

626 """Render a template of this helper. 

627 

628 Args: 

629 subject: Template subject. 

630 args: Template arguments. 

631 mime_type: Template mime type. 

632 

633 Returns: 

634 The rendered content, or an empty string if there is no such template. 

635 """ 

636 

637 

638 tpl = self.root.app.templateMgr.find_template(subject, where=[self], mime_type=mime_type) 

639 if tpl: 

640 res = tpl.render(gws.TemplateRenderInput(args=args)) 

641 return res.content 

642 return ''