Skip to content

Commit

Permalink
[ADD] auth_totp
Browse files Browse the repository at this point in the history
New module for supporting two-factor authentication via time-base
one-time-password (TOTP).

Users (including portal users) can choose to enable two-factor auth in
their user account settings, by scanning a QR code and adding it to an
authenticator app, such as Google Auth, 1Password, etc.

When two-factor is enabled, password-based non-interactive RPC is only
possible by using API keys.

Co-authored-by: Olivier Dony <[email protected]>
  • Loading branch information
xmo-odoo and odony committed Aug 14, 2020
1 parent 63e38da commit a9a6509
Show file tree
Hide file tree
Showing 25 changed files with 1,247 additions and 17 deletions.
3 changes: 3 additions & 0 deletions addons/auth_totp/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import controllers
from . import models
25 changes: 25 additions & 0 deletions addons/auth_totp/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
'name': 'Two-Factor Authentication (TOTP)',
'description': """
Two-Factor Authentication (TOTP)
================================
Allows users to configure two-factor authentication on their user account
for extra security, using time-based one-time passwords (TOTP).
Once enabled, the user will need to enter a 6-digit code as provided
by their authenticator app before being granted access to the system.
All popular authenticator apps are supported.
Note: logically, two-factor prevents password-based RPC access for users
where it is enabled. In order to be able to execute RPC scripts, the user
can setup API keys to replace their main password.
""",
'depends': ['web'],
'category': 'Extra Tools',
'auto_install': True,
'data': [
'security/security.xml',
'views/user_preferences.xml',
'views/templates.xml',
],
}
2 changes: 2 additions & 0 deletions addons/auth_totp/controllers/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import home
38 changes: 38 additions & 0 deletions addons/auth_totp/controllers/home.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
import odoo.addons.web.controllers.main
from odoo import http, _
from odoo.exceptions import AccessDenied
from odoo.http import request


class Home(odoo.addons.web.controllers.main.Home):
@http.route(
'/web/login/totp',
type='http', auth='public', methods=['GET', 'POST'], sitemap=False,
website=True, # website breaks the login layout...
)
def web_totp(self, redirect=None, **kwargs):
if request.session.uid:
return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))

if not request.session.pre_uid:
return http.redirect_with_hash('/web/login')

error = None
if request.httprequest.method == 'POST':
user = request.env['res.users'].browse(request.session.pre_uid)
try:
with user._assert_can_auth():
user._totp_check(int(kwargs['totp_token']))
except AccessDenied:
error = _("Verification failed, please double-check the 6-digit code")
except ValueError:
error = _("Invalid authentication code format.")
else:
request.session.finalize()
return http.redirect_with_hash(self._login_redirect(request.session.uid, redirect=redirect))

return request.render('auth_totp.auth_totp_form', {
'error': error,
'redirect': redirect,
})
3 changes: 3 additions & 0 deletions addons/auth_totp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# -*- coding: utf-8 -*-
from . import ir_http
from . import res_users
13 changes: 13 additions & 0 deletions addons/auth_totp/models/ir_http.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# -*- coding: utf-8 -*-
from odoo import models
from odoo.http import request

class IrHttp(models.AbstractModel):
_inherit = 'ir.http'

def session_info(self):
info = super().session_info()
# because frontend session_info uses this key and is embedded in
# the view source
info["user_id"] = request.session.uid,
return info
197 changes: 197 additions & 0 deletions addons/auth_totp/models/res_users.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
# -*- coding: utf-8 -*-
import base64
import hashlib
import hmac
import io
import logging
import os
import struct
import time

import qrcode
import werkzeug.urls

from odoo import _, api, fields, models
from odoo.addons.base.models.res_users import check_identity
from odoo.exceptions import AccessDenied, UserError

_logger = logging.getLogger(__name__)

class Users(models.Model):
_inherit = 'res.users'

totp_secret = fields.Char(copy=False, groups=".") # no access
totp_enabled = fields.Boolean(string="TOTP enabled", compute='_compute_totp_enabled')

def __init__(self, pool, cr):
init_res = super().__init__(pool, cr)
type(self).SELF_READABLE_FIELDS = self.SELF_READABLE_FIELDS + ['totp_enabled']
return init_res

def _mfa_url(self):
r = super()._mfa_url()
if r is not None:
return r
if self.totp_enabled:
return '/web/login/totp'

@api.depends('totp_secret')
def _compute_totp_enabled(self):
for r, v in zip(self, self.sudo()):
r.totp_enabled = bool(v.totp_secret)

def _rpc_api_keys_only(self):
# 2FA enabled means we can't allow password-based RPC
self.ensure_one()
return self.totp_enabled or super()._rpc_api_keys_only()

def _totp_check(self, code):
sudo = self.sudo()
key = base64.b32decode(sudo.totp_secret.upper())
match = TOTP(key).match(code)
if match is None:
_logger.info("2FA check: FAIL for '%s' (#%s)", self.login, self.id)
raise AccessDenied()
_logger.info("2FA check: SUCCESS for '%s' (#%s)", self.login, self.id)

def _totp_try_setting(self, secret, code):
if self.totp_enabled or self != self.env.user:
_logger.info("2FA enable: REJECT for '%s' (#%s)", self.login, self.id)
return False

match = TOTP(base64.b32decode(secret.upper())).match(code)
if match is None:
_logger.info("2FA enable: REJECT CODE for '%s' (#%s)", self.login, self.id)
return False

self.sudo().totp_secret = secret
_logger.info("2FA enable: SUCCESS for '%s' (#%s)", self.login, self.id)
return True

@check_identity
def totp_disable(self):
if not (self == self.env.user or self.env.user._is_admin() or self.env.su):
_logger.info("2FA disable: REJECT for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id)
return False

self.sudo().write({'totp_secret': False})
_logger.info("2FA disable: SUCCESS for '%s' (#%s) by uid #%s", self.login, self.id, self.env.user.id)
return {'type': 'ir.actions.act_window_close'}

@check_identity
def totp_enable_wizard(self):
if self.env.user != self:
raise UserError(_("Two-factor authentication can only be enabled for yourself"))

if self.totp_enabled:
raise UserError(_("Two-factor authentication already enabled"))

secret_bytes_count = TOTP_SECRET_SIZE // 8
w = self.env['auth_totp.wizard'].create({
'user_id': self.id,
'secret': base64.b32encode(os.urandom(secret_bytes_count)).decode(),
})
return {
'type': 'ir.actions.act_window',
'target': 'new',
'res_model': 'auth_totp.wizard',
'name': _("Enable Two-Factor Authentication"),
'res_id': w.id,
'views': [(False, 'form')],
}

class TOTPWizard(models.TransientModel):
_name = 'auth_totp.wizard'
_description = "Two-Factor Setup Wizard"

user_id = fields.Many2one('res.users', required=True, readonly=True)
secret = fields.Char(required=True, readonly=True)
url = fields.Char(store=True, readonly=True, compute='_compute_qrcode')
qrcode = fields.Binary(
attachment=False, store=True, readonly=True,
compute='_compute_qrcode',
)
code = fields.Char(string="Verification Code", placeholder="6-digit code", size=6)

@api.depends('user_id.login', 'user_id.company_id.display_name', 'secret')
def _compute_qrcode(self):
for w in self:
label = '{0.company_id.display_name}:{0.login}'.format(w.user_id)
w.url = url = werkzeug.urls.url_unparse((
'otpauth', 'totp',
werkzeug.urls.url_quote(label, safe=''),
werkzeug.urls.url_encode({
'secret': w.secret,
'issuer': w.user_id.company_id.display_name,
# apparently a lowercase hash name is anathema to google
# authenticator (error) and passlib (no token)
'algorithm': ALGORITHM.upper(),
'digits': DIGITS,
'period': TIMESTEP,
}), ''
))

data = io.BytesIO()
qrcode.make(url.encode(), box_size=4).save(data, optimise=True, format='PNG')
w.qrcode = base64.b64encode(data.getvalue()).decode()

@check_identity
def enable(self):
try:
c = int(self.code)
except ValueError:
raise UserError(_("The verification code should only contain numbers"))
if self.user_id._totp_try_setting(self.secret, c):
self.secret = '' # empty it, because why keep it until GC?
return {'type': 'ir.actions.act_window_close'}
raise UserError(_('Verification failed, please double-check the 6-digit code'))

# 160 bits, as recommended by HOTP RFC 4226, section 4, R6.
# Google Auth uses 80 bits by default but supports 160.
TOTP_SECRET_SIZE = 160

# The algorithm (and key URI format) allows customising these parameters but
# google authenticator doesn't support it
# https://github.com/google/google-authenticator/wiki/Key-Uri-Format
ALGORITHM = 'sha1'
DIGITS = 6
TIMESTEP = 30

class TOTP:
def __init__(self, key):
self._key = key

def match(self, code, t=None, window=TIMESTEP):
"""
:param code: authenticator code to check against this key
:param int t: current timestamp (seconds)
:param int window: fuzz window to account for slow fingers, network
latency, desynchronised clocks, ..., every code
valid between t-window an t+window is considered
valid
"""
if t is None:
t = time.time()

low = int((t - window) / TIMESTEP)
high = int((t + window) / TIMESTEP) + 1

return next((
counter for counter in range(low, high)
if hotp(self._key, counter) == code
), None)

def hotp(secret, counter):
# C is the 64b counter encoded in big-endian
C = struct.pack(">Q", counter)
mac = hmac.new(secret, msg=C, digestmod=ALGORITHM).digest()
# the data offset is the last nibble of the hash
offset = mac[-1] & 0xF
# code is the 4 bytes at the offset interpreted as a 31b big-endian uint
# (31b to avoid sign concerns). This effectively limits digits to 9 and
# hard-limits it to 10: each digit is normally worth 3.32 bits but the
# 10th is only worth 1.1 (9 digits encode 29.9 bits).
code = struct.unpack_from('>I', mac, offset)[0] & 0x7FFFFFFF
r = code % (10 ** DIGITS)
# NOTE: use text / bytes instead of int?
return r
16 changes: 16 additions & 0 deletions addons/auth_totp/security/security.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<odoo>
<record model="ir.model.access" id="access_auth_totp_wizard">
<field name="name">auth_totp wizard access rules</field>
<field name="model_id" ref="model_auth_totp_wizard"/>
<field name="group_id" ref="base.group_user"/>
<field name="perm_read">1</field>
<field name="perm_write">1</field>
<field name="perm_create">1</field>
<field name="perm_unlink">1</field>
</record>
<record model="ir.rule" id="rule_auth_totp_wizard">
<field name="name">Users can only access their own wizard</field>
<field name="model_id" ref="model_auth_totp_wizard"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
</record>
</odoo>
Loading

0 comments on commit a9a6509

Please sign in to comment.