diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 38b0ba110e..b164712d85 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -13,7 +13,7 @@ jobs: pre-commit: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v2 with: python-version: "3.11" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4004136c3d..cccf883ca5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest name: Detect unreleased dependencies steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | for reqfile in requirements.txt test-requirements.txt ; do if [ -f ${reqfile} ] ; then @@ -62,7 +62,7 @@ jobs: INCLUDE: "${{ matrix.include }}" EXCLUDE: "${{ matrix.exclude }}" steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Install addons and dependencies diff --git a/auth_sms/README.rst b/auth_sms/README.rst new file mode 100644 index 0000000000..84df123809 --- /dev/null +++ b/auth_sms/README.rst @@ -0,0 +1,124 @@ +================================= +Two factor authentication via SMS +================================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:744503b1e32edd0f7f70943a95a2bfff0489985e29584afa7934b48853b943dd + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fserver--auth-lightgray.png?logo=github + :target: https://github.com/OCA/server-auth/tree/16.0/auth_sms + :alt: OCA/server-auth +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-auth-16-0/server-auth-16-0-auth_sms + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/server-auth&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows you to use SMS as second factor for user authentication. + +While SMS is not the most secure way of delivering a secret, it's still safer +than no multi factor authentication at all. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +#. Go to Settings/Technical/SMS providers and configure a provider. + While you can configure multiple ones, the addon will always pick the + topmost active provider for authorization. +#. On a user, enable the `Use SMS verification` checkbox + +The addon understands the following configuration parameters: + + auth_sms.code_chars + The characters used to generate a code. Default is generated from + Python's string.ascii_letters + string.digits. + + You can repeat characters here to make some more or less probable to be + used. + + auth_sms.code_length + The length of a code to be sent to the user via SMS. Default is 8. + + auth_sms.rate_limit_limit, auth_sms.rate_limit_hours + The amount of sms to send for one user within a certain amount of time. + Default is to send at most 10 SMS within 24 hours. + +Usage +===== + +After a user has filled in the correct credentials, she will be taken to a second form where she's asked for the code that has been sent via SMS. + +Known issues / Roadmap +====================== + +* add a config wizard to configure parameters +* add a button to send another code +* make SMS codes time out (currently they live as long as the session they were + generated for) +* make being able to turn on 2FA depend on some security group +* create some auth_mfa_code module and move a lot of code there to have a + common base for all MFA modules that generate some extra code to fill in + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Therp BV + +Contributors +~~~~~~~~~~~~ + +* Holger Brunn + +Other credits +~~~~~~~~~~~~~ + +* Odoo Community Association: `Icon `_. + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/server-auth `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/auth_sms/__init__.py b/auth_sms/__init__.py new file mode 100644 index 0000000000..584f822b8c --- /dev/null +++ b/auth_sms/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import models +from . import controllers +from . import exceptions diff --git a/auth_sms/__manifest__.py b/auth_sms/__manifest__.py new file mode 100644 index 0000000000..431cbb5062 --- /dev/null +++ b/auth_sms/__manifest__.py @@ -0,0 +1,25 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +{ + "name": "Two factor authentication via SMS", + "version": "16.0.1.0.0", + "author": "Therp BV,Odoo Community Association (OCA)", + "license": "AGPL-3", + "category": "Tools", + "website": "https://github.com/OCA/server-auth", + "summary": "Allow users to turn on two factor authentication via SMS", + "depends": [ + "mail", + ], + "demo": [ + "demo/res_users.xml", + "demo/sms_provider.xml", + ], + "data": [ + "views/sms_provider.xml", + "views/res_users.xml", + "security/ir_rule.xml", + "templates/template_code.xml", + "security/ir.model.access.csv", + ], +} diff --git a/auth_sms/controllers/__init__.py b/auth_sms/controllers/__init__.py new file mode 100644 index 0000000000..34e78b4644 --- /dev/null +++ b/auth_sms/controllers/__init__.py @@ -0,0 +1,2 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import auth_sms diff --git a/auth_sms/controllers/auth_sms.py b/auth_sms/controllers/auth_sms.py new file mode 100644 index 0000000000..07c97a0f19 --- /dev/null +++ b/auth_sms/controllers/auth_sms.py @@ -0,0 +1,117 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import base64 +import random + +from odoo import _, http +from odoo.http import request + +from odoo.addons.web.controllers.home import Home + +from ..exceptions import ( + AccessDeniedNoSmsCode, + AccessDeniedSmsRateLimit, + AccessDeniedWrongSmsCode, +) + + +class AuthSms(Home): + @http.route() + def web_login(self, redirect=None, **kw): + try: + return super().web_login(redirect=redirect, **kw) + except AccessDeniedNoSmsCode as exception: + try: + request.env["res.users"]._auth_sms_send(exception.user.id) + except AccessDeniedSmsRateLimit: + return self._show_rate_limit(redirect=None, **kw) + return self._show_sms_entry(redirect=None, **kw) + + def _show_rate_limit(self, redirect=None, **kw): + """User has requested to much sms codes in a short period.""" + # providers here as elsewhere are included in case auth_oauth is installed. + return request.render( + "web.login", + dict( + request.params.copy(), + providers=[], + error=_("Rate limit for SMS exceeded"), + ), + ) + + def _show_sms_entry(self, redirect=None, **kw): + """Show copy of login screen for sms entry.""" + # password will be stored, encrypted, in the session, while + # the secret will be send (and later retrieved) from the browser. + password_bytes = request.params["password"].encode("utf8") + secret = self._auth_sms_generate_secret() + encrypted_password = self._auth_sms_xor(password_bytes, secret) + request.session["auth_sms.password"] = encrypted_password + encoded_secret_string = base64.b64encode(secret).decode("utf8") + return request.render( + "auth_sms.template_code", + dict( + request.params.copy(), + secret=encoded_secret_string, + redirect=redirect, + providers=[], + message=_("Please fill in the code sent to you by SMS"), + **kw + ), + ) + + @http.route("/auth_sms/code", auth="none") + def code(self, password=None, secret=None, redirect=None, **kw): + # IN this case the password argument really contains the sms code. + request.session["auth_sms.code"] = password + encrypted_password = request.session["auth_sms.password"] + decoded_secret_bytes = base64.b64decode((secret or "").encode("utf8")) + decrypted_password = self._auth_sms_xor( + encrypted_password, decoded_secret_bytes + ) + request.params["password"] = decrypted_password.decode("utf8") + request.params["login"] = request.params["user_login"] + try: + return self.web_login( + redirect=redirect, **dict(kw, password=request.params["password"]) + ) + except AccessDeniedWrongSmsCode: + return self._show_wrong_sms_code(secret, redirect=None, **kw) + + def _show_wrong_sms_code(self, secret, redirect=None, **kw): + """Wrong sms code entered, user can try again.""" + del request.session["auth_sms.code"] + return request.render( + "auth_sms.template_code", + dict( + request.params.copy(), + secret=secret, + providers=[], + redirect=redirect, + databases=[], + error=_("Could not verify code"), + **kw + ), + ) + + def _auth_sms_generate_secret(self): + """Generate an OTP for storing the password in the session.""" + otp_size = int( + request.env["ir.config_parameter"] + .sudo() + .get_param( + "auth_sms.otp_size", + 128, + ) + ) + return bytes(bytearray([random.randrange(256) for dummy in range(otp_size)])) + + def _auth_sms_xor(self, password, secret): + """Xor password with secret, to encrypt or decrypt password. + + password and secret should both be byte strings. + """ + assert len(secret) >= len(password) + assert isinstance(password, bytes) + assert isinstance(secret, bytes) + return bytes(bytearray(c ^ otp for c, otp in zip(password, secret))) diff --git a/auth_sms/demo/res_users.xml b/auth_sms/demo/res_users.xml new file mode 100644 index 0000000000..a2bedb4d25 --- /dev/null +++ b/auth_sms/demo/res_users.xml @@ -0,0 +1,11 @@ + + + + auth_sms_demo_user + auth_sms_demo_user + Auth SMS demo user + 0123456789 + auth_sms_demo_user@yourcompany.com + + + diff --git a/auth_sms/demo/sms_provider.xml b/auth_sms/demo/sms_provider.xml new file mode 100644 index 0000000000..3831ed0402 --- /dev/null +++ b/auth_sms/demo/sms_provider.xml @@ -0,0 +1,7 @@ + + + + messagebird + XXXXX + + diff --git a/auth_sms/exceptions.py b/auth_sms/exceptions.py new file mode 100644 index 0000000000..dd164b81b4 --- /dev/null +++ b/auth_sms/exceptions.py @@ -0,0 +1,16 @@ +# Copyright 20192024 Therp BV . +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + + +class AccessDeniedNoSmsCode(Exception): + def __init__(self, user, message=None): + self.user = user + super().__init__(message) + + +class AccessDeniedWrongSmsCode(Exception): + pass + + +class AccessDeniedSmsRateLimit(Exception): + pass diff --git a/auth_sms/models/__init__.py b/auth_sms/models/__init__.py new file mode 100644 index 0000000000..b266e94061 --- /dev/null +++ b/auth_sms/models/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import sms_provider +from . import res_users +from . import auth_sms_code diff --git a/auth_sms/models/auth_sms_code.py b/auth_sms/models/auth_sms_code.py new file mode 100644 index 0000000000..2099c1b320 --- /dev/null +++ b/auth_sms/models/auth_sms_code.py @@ -0,0 +1,13 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from odoo import fields, models + + +class AuthSmsCode(models.Model): + _name = "auth_sms.code" + _description = "Hold a code for a session" + _rec_name = "code" + + code = fields.Char(required=True, index=True) + user_id = fields.Many2one("res.users", required=True, index=True) + session_id = fields.Char(index=True) diff --git a/auth_sms/models/res_users.py b/auth_sms/models/res_users.py new file mode 100644 index 0000000000..a1805c21d2 --- /dev/null +++ b/auth_sms/models/res_users.py @@ -0,0 +1,161 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging +import random +import string +from datetime import datetime, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.http import request + +from ..exceptions import ( + AccessDeniedNoSmsCode, + AccessDeniedSmsRateLimit, + AccessDeniedWrongSmsCode, +) + +_logger = logging.getLogger(__name__) + + +class ResUsers(models.Model): + _inherit = "res.users" + + auth_sms_enabled = fields.Boolean( + "Use SMS verification", + help="Enable SMS authentication in addition to your password", + ) + + @property + def SELF_WRITEABLE_FIELDS(self): + return super().SELF_WRITEABLE_FIELDS + ["auth_sms_enabled"] + + @property + def SELF_READABLE_FIELDS(self): + return super().SELF_READABLE_FIELDS + ["auth_sms_enabled"] + + @api.constrains("auth_sms_enabled") + def _check_auth_sms_enabled(self): + for this in self: + if this.auth_sms_enabled and not this.mobile: + raise UserError( + _("User %s has no mobile phone number!") % this.login, + ) + + def _check_credentials(self, password, env): + super()._check_credentials(password, env) + return self._auth_sms_check_credentials() + + @api.model + def _auth_sms_check_credentials(self): + """if the user has enabled sms validation, check if we have the correct + code in the session""" + if not self.env.user.auth_sms_enabled: + return + code = request and request.session.get("auth_sms.code") + if not code: + raise AccessDeniedNoSmsCode(self.env.user, _("No SMS code")) + if not self.env["auth_sms.code"].search( + [ + ("code", "=", code), + ("user_id", "=", self.id), + ("session_id", "=", request.session.sid), + ] + ): + raise AccessDeniedWrongSmsCode(_("Wrong SMS code")) + + @api.model + def _auth_sms_generate_code(self): + """generate a code to send to the user for second factor""" + choices = ( + self.env["ir.config_parameter"] + .sudo() + .get_param( + "auth_sms.code_chars", + string.ascii_letters + string.digits, + ) + ) + return "".join( + random.choice(choices) + for dummy in range( + int( + self.env["ir.config_parameter"] + .sudo() + .get_param( + "auth_sms.code_length", + 8, + ), + ) + ) + ) + + @api.model + def _auth_sms_send(self, user_id): + """send a code to the user for second factor, save this code with the + session""" + code = self._auth_sms_generate_code() + _logger.debug( + "using SMS code %s for session %s", + code, + request and request.session.sid, + ) + user = self.env["res.users"].browse(user_id) + self.env["auth_sms.code"].sudo().create( + { + "code": code, + "user_id": user.id, + "session_id": request and request.session.sid, + } + ) + if not user.sudo()._auth_sms_check_rate_limit(): + raise AccessDeniedSmsRateLimit(_("SMS rate limit")) + mobile = user.sudo().mobile + if not self.env["sms.provider"].send_sms(mobile, code): + raise UserError(_("Sending SMS failed")) + + def _auth_sms_check_rate_limit(self): + """return false if the user has requested an SMS code too often""" + self.ensure_one() + rate_limit_hours = float( + self.env["ir.config_parameter"] + .sudo() + .get_param( + "auth_sms.rate_limit_hours", + 24, + ) + ) + rate_limit_limit = float( + self.env["ir.config_parameter"] + .sudo() + .get_param( + "auth_sms.rate_limit_limit", + 10, + ) + ) + return ( + rate_limit_hours + and rate_limit_limit + and self.env["auth_sms.code"].search( + [ + ( + "create_date", + ">=", + fields.Datetime.to_string( + datetime.now() - timedelta(hours=rate_limit_hours), + ), + ), + ("user_id", "=", self.id), + ], + count=True, + ) + <= rate_limit_limit + ) + + def _mfa_type(self): + """If auth_sms enabled, disable other totp methods.""" + sudo_self = self.sudo() + result = super(ResUsers, sudo_self)._mfa_type() + if len(self) != 1 or not sudo_self.auth_sms_enabled: + return result + # If we get here, we have one user record that is enabled for sms auth. + return "auth_sms" diff --git a/auth_sms/models/sms_provider.py b/auth_sms/models/sms_provider.py new file mode 100644 index 0000000000..c376f0533b --- /dev/null +++ b/auth_sms/models/sms_provider.py @@ -0,0 +1,75 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +import logging + +import requests + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) + + +class SmsProvider(models.Model): + """Provide fields for API keys or similar, and implement actually sending + SMS via the provider selected in field `provider` by implementing the + function `_send_sms_$provider`. This function is called on the record of + the configured provider, and supposed to return a truthy value if the SMS + is sent, and a falsy value otherwise""" + + # this class is a little overengineerd for the purpose at hand, but this + # could be the preparation for a module base_sms that doesn't rely on + # Odoo's in app purchases as the v12 sms module does + _name = "sms.provider" + _description = "Holds whatever data necessary to send an SMS via some " + "provider" + _rec_name = "provider" + _order = "sequence desc" + + active = fields.Boolean(default=True) + sequence = fields.Integer() + provider = fields.Selection( + [("messagebird", "MessageBird")], + required=True, + ) + api_key = fields.Char() + + def action_send_test(self): + for this in self: + this.send_sms(self.env.user.mobile, "test") + + @api.model + def send_sms(self, number, text, **kwargs): + provider = self.sudo().search([], limit=1) + if not provider: + return False + _logger.debug( + "attempting to send SMS %s to %s via %s", + text, + number, + provider.provider, + ) + return getattr( + provider, + "_send_sms_%s" % provider.provider, + lambda x: False, + )(number, text, **kwargs) + + def _send_sms_messagebird(self, number, text, **kwargs): + self.ensure_one() + result = requests.post( + "https://rest.messagebird.com/messages", + headers={ + "Authorization": "AccessKey %s" % self.api_key, + }, + data={ + "originator": self.env.user.company_id.phone + or self.env.user.company_id.name[:11], + "recipients": number, + "body": text, + }, + timeout=60, + ).json() + _logger.debug(result) + if result.get("errors"): + return False + return result diff --git a/auth_sms/readme/CONFIGURE.rst b/auth_sms/readme/CONFIGURE.rst new file mode 100644 index 0000000000..06d463e00b --- /dev/null +++ b/auth_sms/readme/CONFIGURE.rst @@ -0,0 +1,20 @@ +#. Go to Settings/Technical/SMS providers and configure a provider. + While you can configure multiple ones, the addon will always pick the + topmost active provider for authorization. +#. On a user, enable the `Use SMS verification` checkbox + +The addon understands the following configuration parameters: + + auth_sms.code_chars + The characters used to generate a code. Default is generated from + Python's string.ascii_letters + string.digits. + + You can repeat characters here to make some more or less probable to be + used. + + auth_sms.code_length + The length of a code to be sent to the user via SMS. Default is 8. + + auth_sms.rate_limit_limit, auth_sms.rate_limit_hours + The amount of sms to send for one user within a certain amount of time. + Default is to send at most 10 SMS within 24 hours. diff --git a/auth_sms/readme/CONTRIBUTORS.rst b/auth_sms/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..b120a956f8 --- /dev/null +++ b/auth_sms/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Holger Brunn diff --git a/auth_sms/readme/CREDITS.rst b/auth_sms/readme/CREDITS.rst new file mode 100644 index 0000000000..cc056a80d6 --- /dev/null +++ b/auth_sms/readme/CREDITS.rst @@ -0,0 +1 @@ +* Odoo Community Association: `Icon `_. diff --git a/auth_sms/readme/DESCRIPTION.rst b/auth_sms/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..fed5250e63 --- /dev/null +++ b/auth_sms/readme/DESCRIPTION.rst @@ -0,0 +1,4 @@ +This module allows you to use SMS as second factor for user authentication. + +While SMS is not the most secure way of delivering a secret, it's still safer +than no multi factor authentication at all. diff --git a/auth_sms/readme/ROADMAP.rst b/auth_sms/readme/ROADMAP.rst new file mode 100644 index 0000000000..9c36a54f15 --- /dev/null +++ b/auth_sms/readme/ROADMAP.rst @@ -0,0 +1,7 @@ +* add a config wizard to configure parameters +* add a button to send another code +* make SMS codes time out (currently they live as long as the session they were + generated for) +* make being able to turn on 2FA depend on some security group +* create some auth_mfa_code module and move a lot of code there to have a + common base for all MFA modules that generate some extra code to fill in diff --git a/auth_sms/readme/USAGE.rst b/auth_sms/readme/USAGE.rst new file mode 100644 index 0000000000..7f209fda78 --- /dev/null +++ b/auth_sms/readme/USAGE.rst @@ -0,0 +1 @@ +After a user has filled in the correct credentials, she will be taken to a second form where she's asked for the code that has been sent via SMS. diff --git a/auth_sms/security/ir.model.access.csv b/auth_sms/security/ir.model.access.csv new file mode 100644 index 0000000000..e02d9a9029 --- /dev/null +++ b/auth_sms/security/ir.model.access.csv @@ -0,0 +1,4 @@ +"id","name","model_id:id","group_id:id","perm_read","perm_write","perm_create","perm_unlink" +access_sms_provider,access_sms_provider,model_sms_provider,base.group_system,1,1,1,1 +access_auth_sms_code,access_auth_sms_code_system,model_auth_sms_code,base.group_system,1,1,1,1 +access_auth_sms_code_all,access_auth_sms_code_all,model_auth_sms_code,,1,0,0,0 diff --git a/auth_sms/security/ir_rule.xml b/auth_sms/security/ir_rule.xml new file mode 100644 index 0000000000..874e9f8432 --- /dev/null +++ b/auth_sms/security/ir_rule.xml @@ -0,0 +1,14 @@ + + + + Restrict users to own SMS codes + + [('user_id', '=', user.id)] + + + Lift restrictions to SMS codes + + + [(1, '=', 1)] + + diff --git a/auth_sms/static/description/icon.png b/auth_sms/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/auth_sms/static/description/icon.png differ diff --git a/auth_sms/static/description/index.html b/auth_sms/static/description/index.html new file mode 100644 index 0000000000..4ccb6d89c6 --- /dev/null +++ b/auth_sms/static/description/index.html @@ -0,0 +1,476 @@ + + + + + +Two factor authentication via SMS + + + +
+

Two factor authentication via SMS

+ + +

Beta License: AGPL-3 OCA/server-auth Translate me on Weblate Try me on Runboat

+

This module allows you to use SMS as second factor for user authentication.

+

While SMS is not the most secure way of delivering a secret, it’s still safer +than no multi factor authentication at all.

+

Table of contents

+ +
+

Configuration

+
    +
  1. Go to Settings/Technical/SMS providers and configure a provider. +While you can configure multiple ones, the addon will always pick the +topmost active provider for authorization.
  2. +
  3. On a user, enable the Use SMS verification checkbox
  4. +
+

The addon understands the following configuration parameters:

+
+
+
auth_sms.code_chars
+

The characters used to generate a code. Default is generated from +Python’s string.ascii_letters + string.digits.

+

You can repeat characters here to make some more or less probable to be +used.

+
+
auth_sms.code_length
+
The length of a code to be sent to the user via SMS. Default is 8.
+
auth_sms.rate_limit_limit, auth_sms.rate_limit_hours
+
The amount of sms to send for one user within a certain amount of time. +Default is to send at most 10 SMS within 24 hours.
+
+
+
+
+

Usage

+

After a user has filled in the correct credentials, she will be taken to a second form where she’s asked for the code that has been sent via SMS.

+
+
+

Known issues / Roadmap

+
    +
  • add a config wizard to configure parameters
  • +
  • add a button to send another code
  • +
  • make SMS codes time out (currently they live as long as the session they were +generated for)
  • +
  • make being able to turn on 2FA depend on some security group
  • +
  • create some auth_mfa_code module and move a lot of code there to have a +common base for all MFA modules that generate some extra code to fill in
  • +
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+ +
+
+

Other credits

+
    +
  • Odoo Community Association: Icon.
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/server-auth project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/auth_sms/templates/template_code.xml b/auth_sms/templates/template_code.xml new file mode 100644 index 0000000000..072d3fb7b2 --- /dev/null +++ b/auth_sms/templates/template_code.xml @@ -0,0 +1,21 @@ + + + + diff --git a/auth_sms/tests/__init__.py b/auth_sms/tests/__init__.py new file mode 100644 index 0000000000..75b2585923 --- /dev/null +++ b/auth_sms/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from . import common +from . import test_auth_sms diff --git a/auth_sms/tests/common.py b/auth_sms/tests/common.py new file mode 100644 index 0000000000..6638d7865b --- /dev/null +++ b/auth_sms/tests/common.py @@ -0,0 +1,48 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from contextlib import contextmanager +from functools import partial + +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request as WerkzeugRequest + +from odoo import http +from odoo.tests.common import TransactionCase + + +class Common(TransactionCase): + def setUp(self): + super(Common, self).setUp() + self.odoo_root = http.Root() + self.session = self.odoo_root.session_store.new() + self.env["res.users"]._register_hook() + self.demo_user = self.env.ref("auth_sms.demo_user") + self.env["auth_sms.code"].search([]).unlink() + + @contextmanager + def _request(self, path, method="POST", data=None): + """yield request, endpoint for given http request data""" + werkzeug_env = EnvironBuilder( + method=method, + path=path, + data=data, + headers=[("cookie", "session_id=%s" % self.session.sid)], + environ_base={ + "HTTP_HOST": "localhost", + "REMOTE_ADDR": "127.0.0.1", + }, + ).get_environ() + werkzeug_request = WerkzeugRequest(werkzeug_env) + self.odoo_root.setup_session(werkzeug_request) + werkzeug_request.session.db = self.env.cr.dbname + self.odoo_root.setup_db(werkzeug_request) + self.odoo_root.setup_lang(werkzeug_request) + + request = http.HttpRequest(werkzeug_request) + request._env = self.env + with request: + routing_map = self.env["ir.http"].routing_map() + endpoint, dummy = routing_map.bind_to_environ(werkzeug_env).match( + return_rule=False, + ) + yield request, partial(endpoint, **request.params) diff --git a/auth_sms/tests/test_auth_sms.py b/auth_sms/tests/test_auth_sms.py new file mode 100644 index 0000000000..8c7656c95c --- /dev/null +++ b/auth_sms/tests/test_auth_sms.py @@ -0,0 +1,86 @@ +# Copyright 2019 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). +from unittest.mock import patch + +from .common import Common + + +class TestAuthSms(Common): + def test_auth_sms_login_no_2fa(self): + # admin doesn't have sms verification turned on + with self._request( + "/web/login", + method="POST", + data={ + "login": self.env.user.login, + "password": self.env.user.login, + }, + ) as (request, endpoint): + response = endpoint() + self.assertFalse(response.template) + + def test_auth_sms_login(self): + # first request: login + with self._request( + "/web/login", + data={ + "login": self.demo_user.login, + "password": self.demo_user.login, + }, + ) as (request, endpoint), patch( + "odoo.addons.auth_sms.models.sms_provider.requests.post", + ) as mock_request_post: + mock_request_post.return_value.json.return_value = { + "originator": "originator", + } + response = endpoint() + self.assertEqual(response.template, "auth_sms.template_code") + self.assertTrue(request.session["auth_sms.password"]) + mock_request_post.assert_called_once() + self.odoo_root.session_store.save(request.session) + + # then fill in a wrong code + with self._request( + "/auth_sms/code", + data={ + "secret": response.qcontext["secret"], + "user_login": response.qcontext["login"], + "password": "wrong code", + }, + ) as (request, endpoint): + response = endpoint() + self.assertEqual(response.template, "auth_sms.template_code") + self.assertTrue(response.qcontext["error"]) + + # fill the correct code + with self._request( + "/auth_sms/code", + data={ + "secret": response.qcontext["secret"], + "user_login": response.qcontext["login"], + "password": mock_request_post.mock_calls[0][2]["data"]["body"], + }, + ) as (request, endpoint): + response = endpoint() + self.assertFalse(response.is_qweb) + self.assertTrue(response.data) + + def test_auth_sms_rate_limit(self): + # request codes until we hit the rate limit + with self._request( + "/web/login", + data={ + "login": self.demo_user.login, + "password": self.demo_user.login, + }, + ) as (request, endpoint), patch( + "odoo.addons.auth_sms.models.sms_provider.requests.post", + ) as mock_request_post: + mock_request_post.return_value.json.return_value = { + "originator": "originator", + } + for _i in range(9): + response = endpoint() + self.assertNotIn("error", response.qcontext) + response = endpoint() + self.assertTrue(response.qcontext["error"]) diff --git a/auth_sms/views/res_users.xml b/auth_sms/views/res_users.xml new file mode 100644 index 0000000000..a28bebb23b --- /dev/null +++ b/auth_sms/views/res_users.xml @@ -0,0 +1,24 @@ + + + + res.users + + + +
+
+
+
+
+ + res.users + + + + + + + +
diff --git a/auth_sms/views/sms_provider.xml b/auth_sms/views/sms_provider.xml new file mode 100644 index 0000000000..8793a54b39 --- /dev/null +++ b/auth_sms/views/sms_provider.xml @@ -0,0 +1,29 @@ + + + + sms.provider + + + + + + +