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
« 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.
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": ...}``.
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.
15Example::
17 auth.mfa+ {
18 type "email"
19 uid "AUTH_MFA_EMAIL"
21 templates+ {
22 type "text"
23 subject "email.subject"
24 text "Your login code"
25 }
27 templates+ {
28 type "text"
29 subject "email.body"
30 text "Hello {{user.displayName}}, your code is {{otp}}."
31 }
32 }
33"""
35from typing import Optional, cast
37import gws
38import gws.base.auth
39import gws.plugin.email_helper
40import gws.lib.otp
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."""
47 templates: Optional[list[gws.ext.config.template]]
48 """Templates for the email subject and body."""
51@gws.ext.object.authMultiFactorAdapter('email')
52class Object(gws.base.auth.mfa.Object):
53 """Email multi-factor adapter."""
55 templates: list[gws.Template]
56 """Templates for the email subject and body."""
58 def configure(self):
59 self.templates = self.create_children(gws.ext.object.template, self.cfg('templates'))
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
69 def verify(self, mfa, payload):
70 ok = self.check_totp(mfa, payload.get('code'))
71 return self.verify_attempt(mfa, ok)
73 ##
75 def generate_and_send(self, mfa: gws.AuthMultiFactorTransaction):
76 """Generate a new code and send it to the user by email.
78 A new random secret is stored in the transaction before the code is
79 generated.
81 Args:
82 mfa: The transaction.
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()
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 )
101 email_helper = cast(gws.plugin.email_helper.Object, self.root.app.helper('email'))
102 email_helper.send_mail(message)
104 def render_template(self, subject, args, mime_type=None):
105 """Render a template of the adapter.
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.
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 ''