odoo12之应用:一、双因子验证(Two-factor authentication, 2FA)(HOTP,TOTP)附源码
前言
双因子认证:双因子认证(2FA)是指结合密码以及实物(信用卡、SMS手机、令牌或指纹等生物标志)两种条件对用户进行认证的方法。--百度百科
跟我一样"老"的网瘾少年想必一定见过买点卡后上面送的密保(类似但不完全一样),还有"将军令",以及网银的网盾,是一种二次验证的机制;它通常是6位的数字,每次使用后(HOTP)或者一定时间后(TOTP)都将会刷新,大大加大了用户的安全性,OTP(One-Time Password)分为HOTP(HMAC-based One-Time Password)和TOTP(Time-based One-Time Password)。
HOTP是基于 HMAC 算法加密的一次性密码,以事件同步机制,把事件次序(counter)及相同的密钥(secret)作为输入,通过 HASH 算法运算出一致的密码。
TOTP是基于时间戳算法的一次性密码,基于客户端的时间和服务器的时间及相同的密钥(secret)作为输入,产生数字进行对比,这就需要客户端的时间和服务器的时间保持相对的一致性。
Odoo12集成双因子认证
为了让odoo12的登录也可以使用双因子认证以提高安全性,我们需要:
复制代码
1、实现OTP验证逻辑
2、为ODOO用户界面展示二维码
3、为管理员用户提供OTP开关
4、在登录界面增加对OTP的验证
复制代码
我们需要依赖的包:
pip install pyotp
pip install pyqrcode
pip install pypng
实现OTP验证逻辑
首先,我们需要对res.users用户进行重写,添加OTP验证逻辑
复制代码
# -*- coding: utf-8 -*-
import base64
import pyotp
import pyqrcode
import io
from odoo import models, fields, api, _, tools
from odoo.http import request
from odoo.exceptions import AccessDenied
import logging
_logger = logging.getLogger(__name__)
class ResUsers(models.Model):
_inherit = 'res.users'
otp_type = fields.Selection(selection=[('time', _('Time based')), ('count', _('Counter based'))], default='time',
string="Type",
help="Type of 2FA, time = new code for each period, counter = new code for each login")
otp_secret = fields.Char(string="Secret", size=16, help='16 character base32 secret',
default=lambda self: pyotp.random_base32())
otp_counter = fields.Integer(string="Counter", default=0)
otp_digits = fields.Integer(string="Digits", default=6, help="Length of the code")
otp_period = fields.Integer(string="Period", default=30, help="Seconds to update code")
otp_qrcode = fields.Binary(compute="_compute_otp_qrcode")
otp_uri = fields.Char(compute='_compute_otp_uri', string="URI")
# 生成二维码
@api.model
def create_qr_code(self, uri):
buffer = io.BytesIO()
qr = pyqrcode.create(uri)
qr.png(buffer, scale=3)
return base64.b64encode(buffer.getvalue()).decode()
# 将二维码的值赋给otp_qrcode变量
@api.depends('otp_uri')
def _compute_otp_qrcode(self):
self.ensure_one()
self.otp_qrcode = self.create_qr_code(self.otp_uri)
# 计算otp_uri
@api.depends('otp_type', 'otp_period', 'otp_digits', 'otp_secret', 'company_id', 'otp_counter')
def _compute_otp_uri(self):
self.ensure_one()
if self.otp_type == 'time':
self.otp_uri = pyotp.utils.build_uri(secret=self.otp_secret, name=self.login,
issuer_name=self.company_id.name, period=self.otp_period)
else:
self.otp_uri = pyotp.utils.build_uri(secret=self.otp_secret, name=self.login,
initial_count=self.otp_counter, issuer_name=self.company_id.name,
digits=self.otp_digits)
# 验证otp验证码是否正确
@api.model
def check_otp(self, otp_code):
res_user = self.env['res.users'].browse(self.env.uid)
if res_user.otp_type == 'time':
totp = pyotp.TOTP(res_user.otp_secret)
return totp.verify(otp_code)
elif res_user.otp_type == 'count':
hotp = pyotp.HOTP(res_user.otp_secret)
# 允许用户不小心多点20次,但是已经用过的码则无法再次使用
for count in range(res_user.otp_counter, res_user.otp_counter + 20):
if count > 0 and hotp.verify(otp_code, count):
res_user.otp_counter = count + 1
return True
return False
# 覆盖原生_check_credentials,增加双因子验证
def _check_credentials(self, password):
super(ResUsers, self)._check_credentials(password)
# 判断是否打开双因子验证并校验验证码
if self.company_id.is_open_2fa and not self.check_otp(request.params.get('tfa_code')):
# pass
raise AccessDenied(_('Validation Code Error!'))
复制代码
在这里,我们继承了res.users,添加了如下方法:
复制代码
_compute_otp_uri: 计算otp_uri
create_qr_code: 通过计算的otp_uri生成二维码
_compute_otp_qrcode: 调用create_qr_code生成二维码,赋值给otp_qrcode变量
check_otp: 用于验证otp验证码是否正确
_check_credentials: 覆盖原生_check_credentials,判断双因子的开关,调用check_otp进行双因子验证
复制代码
_check_credentials方法中,我们判断了双因子的开关,而双因子开关是以公司为单位的,因此我们还需要对res.company进行继承添加字段:
复制代码
# -*- coding: utf-8 -*-
from odoo import models, api, fields
class ResCompany(models.Model):
_inherit = "res.company"
is_open_2fa = fields.Boolean(string="Open 2FA", default=False)
复制代码
为ODOO用户界面展示二维码
我们写好逻辑后,需要在用户界面中将二维码以及配置展示出来:
复制代码
res.users.form
res.users
res.users.preferences.form.otp
res.users
复制代码
效果如下:
为管理员用户提供OTP开关
我们需要让OTP可以为管理员配置,我们将它加入到res.config.settings的常规设置中:
首先,继承模型添加关联字段,is_open_2fa与company_id里的is_open_2fa关联:
复制代码
# -*- coding: utf-8 -*-
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
is_open_2fa = fields.Boolean(related='company_id.is_open_2fa', string="Open 2FA", readonly=False)
复制代码
然后,我们将它展示到常规设置->用户当中
复制代码
res.config.settings.view.form.inherit.base.setup
res.config.settings
复制代码
效果如下:
在登录界面增加对OTP的验证
最后,我们修改登录界面,在页面中增加对otp的验证。
首先,我们需要新增输入页面:
复制代码
复制代码
然后,我们需要对/web/login路由进行修改,更改它的跳转逻辑和验证逻辑,在controller中添加main.py:
复制代码
# -*- coding: utf-8 -*-
import odoo
import logging
from odoo import http, _
from odoo.addons.web.controllers.main import ensure_db, Home
from passlib.context import CryptContext
from odoo.http import request
default_crypt_context = CryptContext(
['pbkdf2_sha512', 'md5_crypt'],
deprecated=['md5_crypt'],
)
_logger = logging.getLogger(__name__)
class WebHome(odoo.addons.web.controllers.main.Home):
# Override by misterling
@http.route('/web/login', type='http', auth="none", sitemap=False)
def web_login(self, redirect=None, **kw):
ensure_db()
request.params['login_success'] = False
if request.httprequest.method == 'GET' and redirect and request.session.uid:
return http.redirect_with_hash(redirect)
if not request.uid:
request.uid = odoo.SUPERUSER_ID
values = request.params.copy()
try:
values['databases'] = http.db_list()
except odoo.exceptions.AccessDenied:
values['databases'] = None
if request.httprequest.method == 'POST':
old_uid = request.uid
try:
request.env.cr.execute(
"SELECT COALESCE(company_id, NULL), COALESCE(password, '') FROM res_users WHERE login=%s",
[request.params['login']]
)
res = request.env.cr.fetchone()
if not res:
raise odoo.exceptions.AccessDenied(_('Wrong login account'))
[company_id, hashed] = res
if company_id and request.env['res.company'].browse(company_id).is_open_2fa:
# 验证密码正确性
valid, replacement = default_crypt_context.verify_and_update(request.params['password'], hashed)
if replacement is not None:
self._set_encrypted_password(self.env.user.id, replacement)
if valid:
response = request.render('auth_2FA.2fa_auth', values)
response.headers['X-Frame-Options'] = 'DENY'
return response
else:
raise odoo.exceptions.AccessDenied()
# 没有打开双因子验证
uid = request.session.authenticate(request.session.db, request.params['login'],
request.params['password'])
request.params['login_success'] = True
return http.redirect_with_hash(self._login_redirect(uid, redirect=redirect))
except odoo.exceptions.AccessDenied as e:
request.uid = old_uid
if e.args == odoo.exceptions.AccessDenied().args:
values['error'] = _("Wrong login/password")
else:
values['error'] = e.args[0]
else:
if 'error' in request.params and request.params.get('error') == 'access':
values['error'] = _('Only employee can access this database. Please contact the administrator.')
if 'log
:
The Switch to open 2FA