前言
雙因子認證:雙因子認證(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用戶界面展示二維碼
我們寫好邏輯后,需要在用戶界面中將二維碼以及配置展示出來:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<!-- 設置->用戶&公司->用戶界面-->
<record id="view_users_form" model="ir.ui.view">
<field name="name">res.users.form</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<notebook colspan="4" position="inside">
<page string="2FA">
<group col="4" colspan="4">
<field name="otp_secret"/>
<field name="otp_type"/>
<field name="otp_counter"
attrs="{'invisible':[('otp_type', '==', 'time')], 'readonly': True}"/>
<field name="otp_digits" attrs="{'invisible':[('otp_type', '==', 'time')]}"/>
<field name="otp_period" attrs="{'invisible':[('otp_type', '==', 'count')]}"/>
</group>
<div class="row" style="display: block;text-align: center;">
<field name="otp_qrcode" widget="image" nolabel="1"/>
</div>
<div class="row" style="display: block;text-align: center;">
<label for="otp_uri"/>:
<field name="otp_uri"/>
</div>
</page>
</notebook>
</field>
</record>
<!-- 右上角首選項界面-->
<record id="view_users_form_simple_modif" model="ir.ui.view">
<field name="name">res.users.preferences.form.otp</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form_simple_modif"/>
<field name="arch" type="xml">
<footer position="before">
<div class="o_horizontal_separator">OTP</div>
<div class="row" style="display:block;text-align:center">
<field name="otp_qrcode" widget="image" nolabel="1"/>
</div>
<div class="row" style="display:block;text-align:center">
<field name="otp_uri" nolabel="1"/>
</div>
</footer>
</field>
</record>
</data>
</odoo>
效果如下:
為管理員用戶提供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)
然后,我們將它展示到常規設置->用戶當中
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="res_config_settings_view_form" model="ir.ui.view">
<field name="name">res.config.settings.view.form.inherit.base.setup</field>
<field name="model">res.config.settings</field>
<field name="priority" eval="100"/>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="http://div[@id='user_default_rights']" position="inside">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="is_open_2fa"/>
</div>
<div class="o_setting_right_pane">
<label for="is_open_2fa"/>
<div class="text-muted">
The Switch to open 2FA
</div>
</div>
</div>
</xpath>
</field>
</record>
</odoo>
效果如下:
在登錄界面增加對OTP的驗證
最后,我們修改登錄界面,在頁面中增加對otp的驗證。
首先,我們需要新增輸入頁面:
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="auth_2FA.2fa_auth" name="TFA_auth">
<t t-call="web.login_layout">
<form class="oe_login_form" role="form" t-attf-action="/web/login/2fa_auth{{ '?debug' if debug else '' }}"
method="post" onsubmit="this.action = this.action + location.hash">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<div class="form-group field-login">
<label for="tfa_code">Validation Code</label>
<input type="text" placeholder="Please input 2FA digits number" name="tfa_code" t-att-value="tfa_code" id="tfa_code"
t-attf-class="form-control #{'form-control-sm' if form_small else ''}" required="required"
autofocus="autofocus" autocapitalize="off"/>
</div>
<p class="alert alert-danger" t-if="error" role="alert">
<t t-esc="error"/>
</p>
<p class="alert alert-success" t-if="message" role="status">
<t t-esc="message"/>
</p>
<div t-attf-class="clearfix oe_login_buttons text-center mb-1 {{'pt-2' if form_small else 'pt-3'}}">
<button type="submit" class="btn btn-primary btn-block">Log in</button>
<button type="button" class="btn btn-primary btn-block" onclick="window.location.href='/web/login'">Return</button>
<div class="o_login_auth"/>
</div>
<input type="hidden" name="login" t-att-value="login"/>
<input type="hidden" name="password" t-att-value="password"/>
<input type="hidden" name="redirect" t-att-value="redirect"/>
</form>
</t>
</template>
</odoo>
然后,我們需要對/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 'login' not in values and request.session.get('auth_login'):
values['login'] = request.session.get('auth_login')
if not odoo.tools.config['list_db']:
values['disable_database_manager'] = True
# otherwise no real way to test debug mode in template as ?debug =>
# values['debug'] = '' but that's also the fallback value when
# missing variables in qweb
if 'debug' in values:
values['debug'] = True
response = request.render('web.login', values)
response.headers['X-Frame-Options'] = 'DENY'
return response
@http.route('/web/login/2fa_auth', type='http', auth="none")
def web_login_2fa_auth(self, redirect=None, **kw):
ensure_db()
request.params['login_success'] = False
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
old_uid = request.uid
try:
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]
if not odoo.tools.config['list_db']:
values['disable_database_manager'] = True
if 'login' not in values and request.session.get('auth_login'):
values['login'] = request.session.get('auth_login')
if 'debug' in values:
values['debug'] = True
response = request.render('auth_2FA.2fa_auth', values)
response.headers['X-Frame-Options'] = 'DENY'
return response
我們新增了otp驗證路由,將登錄邏輯增加到otp驗證路由中,然后更改login路由,增加以下邏輯:
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()
這段代碼的作用是判斷otp是否開啟并進行密碼驗證(不登錄,不生成session),通過密碼驗證跳轉到otp驗證頁面。效果如下:
增加語言支持
由于使用的是英文,我們需要增加中文翻譯支持。
Tip:需要中文翻譯的語句在非字段描述中需要使用_進行包裹,如:
_("Wrong login/password")
打開開發者模式,設置->翻譯->導出翻譯->選擇簡體中文,PO文件,要導出的應用為two factor authentication。新建auth_2FA/i18n目錄,將導出的文件復制到i18n目錄下,修改里面英文內容對應的中文內容后,重啟服務器即可生效。
如果沒有生效,請在設置->翻譯->加載翻譯中重新加載。
TODO
對于第一次使用的用戶,在用戶登錄時,在登錄界面中展示二維碼。實現方案:為res.users增加"是否第一次使用"字段,在第一次登錄后展示二維碼,并為其賦值為True。具體的邏輯有興趣的朋友可以先行嘗試實現。
APP下載
功能需要配套app使用,請自己手機搜索"谷歌驗證器"下載使用,或使用其他可用otp軟件替代。
代碼地址
從github中下載:
git clone //github.com/lingjiawen/auth_2FA.git
從odoo官方app中下載://apps.odoo.com/apps/modules/12.0/auth_2FA/
聲明
原文來自于博客園(//www.cnblogs.com/ljwTiey/p/11505473.html)
轉載請注明文章出處,文章如有任何版權問題,請聯系作者刪除。
代碼僅供學習使用,未經作者允許,禁止使用于商業用途。
合作或問題反饋,聯系郵箱:26476395@qq.com
轉載自://www.cnblogs.com/ljwTiey/p/11505473.html