Coverage for gws-app/gws/plugin/auth_mfa/email/__init__.py: 0%

35 statements  

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

1"""Multi-factor authentication with a one-time code sent by email. 

2 

3When a transaction starts, the adapter generates a random secret, creates a 

4TOTP code from it and sends the code to the ``email`` address of the user. A 

5restart sends a new code with a new secret. Users without an email address 

6cannot start a transaction. The client sends the code back as 

7``{"code": ...}``. 

8 

9The email subject is rendered from the template ``email.subject``, the body 

10from the template ``email.body``, once as ``text/plain`` and once as 

11``text/html``; the HTML part is only added if it is not empty. Templates get 

12the arguments ``user`` and ``otp``. The mail is sent with the ``email`` helper 

13(``gws.plugin.email_helper``), which must be configured. 

14 

15Example:: 

16 

17 auth.mfa+ { 

18 type "email" 

19 uid "AUTH_MFA_EMAIL" 

20 

21 templates+ { 

22 type "text" 

23 subject "email.subject" 

24 text "Your login code" 

25 } 

26 

27 templates+ { 

28 type "text" 

29 subject "email.body" 

30 text "Hello {{user.displayName}}, your code is {{otp}}." 

31 } 

32 } 

33""" 

34 

35from typing import Optional, cast 

36 

37import gws 

38import gws.base.auth 

39import gws.plugin.email_helper 

40import gws.lib.otp 

41 

42 

43@gws.ext.config.authMultiFactorAdapter('email') 

44class Config(gws.base.auth.mfa.Config): 

45 """Multi-factor authentication with a one-time code sent by email.""" 

46 

47 templates: Optional[list[gws.ext.config.template]] 

48 """Templates for the email subject and body.""" 

49 

50 

51@gws.ext.object.authMultiFactorAdapter('email') 

52class Object(gws.base.auth.mfa.Object): 

53 """Email multi-factor adapter.""" 

54 

55 templates: list[gws.Template] 

56 """Templates for the email subject and body.""" 

57 

58 def configure(self): 

59 self.templates = self.create_children(gws.ext.object.template, self.cfg('templates')) 

60 

61 def start(self, user): 

62 if not user.email: 

63 gws.log.warning(f'email: cannot start, {user.uid=}: no email') 

64 return 

65 mfa = super().start(user) 

66 self.generate_and_send(mfa) 

67 return mfa 

68 

69 def verify(self, mfa, payload): 

70 ok = self.check_totp(mfa, payload.get('code')) 

71 return self.verify_attempt(mfa, ok) 

72 

73 ## 

74 

75 def generate_and_send(self, mfa: gws.AuthMultiFactorTransaction): 

76 """Generate a new code and send it to the user by email. 

77 

78 A new random secret is stored in the transaction before the code is 

79 generated. 

80 

81 Args: 

82 mfa: The transaction. 

83 

84 Raises: 

85 ``gws.plugin.email_helper.Error``: If the email cannot be sent. 

86 """ 

87 # NB regenerate secret on each attempt 

88 mfa.secret = gws.lib.otp.random_secret() 

89 

90 args = { 

91 'user': mfa.user, 

92 'otp': self.generate_totp(mfa), 

93 } 

94 message = gws.plugin.email_helper.Message( 

95 subject=self.render_template('email.subject', args), 

96 mailTo=mfa.user.email, 

97 text=self.render_template('email.body', args, mime_type='text/plain'), 

98 html=self.render_template('email.body', args, mime_type='text/html'), 

99 ) 

100 

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

102 email_helper.send_mail(message) 

103 

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

105 """Render a template of the adapter. 

106 

107 Args: 

108 subject: Template subject, for example ``email.body``. 

109 args: Template arguments. 

110 mime_type: Output mime type of the template to find. 

111 

112 Returns: 

113 The rendered content, or an empty string if no template is found. 

114 """ 

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

116 if tpl: 

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

118 return res.content 

119 return ''