feat(fusion_plating_shopfloor): res.users tablet PIN fields + hash helpers (P6.1.1)
PBKDF2-SHA256 + 16-byte salt + 200k iterations on res.users. Format of the stored hash string is <salt_hex>$<digest_hex>. Field is manager-readable only (groups=group_fusion_plating_manager); helpers that need to read or write it use .sudo() internally so operator-level callers can still set/verify their own PIN. Adds set_tablet_pin / verify_tablet_pin / clear_tablet_pin model methods + action_open_tablet_pin_setup that triggers the OWL setup modal (Phase 6.2). Tests cover hash uniqueness, verify, clear with chatter post, and the 4-digit format guard. Tests verified on entech: -u fusion_plating_shopfloor --test-tags fp_tablet_pin Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,3 +8,4 @@ from . import fp_bake_window
|
|||||||
from . import fp_first_piece_gate
|
from . import fp_first_piece_gate
|
||||||
from . import fp_operator_queue
|
from . import fp_operator_queue
|
||||||
from . import fp_tank
|
from . import fp_tank
|
||||||
|
from . import res_users
|
||||||
|
|||||||
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
128
fusion_plating/fusion_plating_shopfloor/models/res_users.py
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc.
|
||||||
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
# Part of the Fusion Plating product family.
|
||||||
|
"""Tablet-PIN extensions on res.users (Phase 6 tablet redesign).
|
||||||
|
|
||||||
|
Adds the 4-digit PIN gate fields + helpers used by /fp/tablet/* endpoints
|
||||||
|
and the FpTabletLock OWL component. PIN is stored as a salted PBKDF2-SHA256
|
||||||
|
hash; never plaintext.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from odoo import _, fields, models
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
|
||||||
|
# PBKDF2 iteration count. ~50ms verify on entech-class hardware. Safe
|
||||||
|
# against brute-force even if the DB leaks.
|
||||||
|
_PBKDF2_ITERATIONS = 200_000
|
||||||
|
|
||||||
|
|
||||||
|
class ResUsers(models.Model):
|
||||||
|
_inherit = 'res.users'
|
||||||
|
|
||||||
|
x_fc_tablet_pin_hash = fields.Char(
|
||||||
|
string='Tablet PIN (hashed)',
|
||||||
|
groups='fusion_plating.group_fusion_plating_manager',
|
||||||
|
help='PBKDF2-SHA256 hash + salt of the user\'s 4-digit tablet '
|
||||||
|
'PIN. Format: <salt_hex>$<digest_hex>. Never readable to '
|
||||||
|
'non-managers; never logged.',
|
||||||
|
)
|
||||||
|
x_fc_tablet_pin_set_date = fields.Datetime(
|
||||||
|
string='Tablet PIN Set Date',
|
||||||
|
help='When the current PIN was last set or changed.',
|
||||||
|
)
|
||||||
|
x_fc_tablet_pin_failed_count = fields.Integer(
|
||||||
|
string='Failed PIN Attempts',
|
||||||
|
default=0,
|
||||||
|
help='Sequential failed unlock attempts since the last success. '
|
||||||
|
'Resets to 0 on a correct PIN.',
|
||||||
|
)
|
||||||
|
x_fc_tablet_locked_until = fields.Datetime(
|
||||||
|
string='Tablet Lockout Until',
|
||||||
|
help='Wall-clock time at which the per-user lockout expires. '
|
||||||
|
'Null when not locked. Set after the configured fail '
|
||||||
|
'threshold (default 5) is reached.',
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_tablet_pin(pin, salt=None):
|
||||||
|
"""Hash `pin` with optional salt. Returns "salt_hex$digest_hex"."""
|
||||||
|
if salt is None:
|
||||||
|
salt = secrets.token_bytes(16)
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||||
|
)
|
||||||
|
return f"{salt.hex()}${digest.hex()}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _verify_tablet_pin_hash(pin, stored):
|
||||||
|
"""Constant-time verify of `pin` against a stored hash string."""
|
||||||
|
if not stored or '$' not in stored:
|
||||||
|
return False
|
||||||
|
salt_hex, expected_hex = stored.split('$', 1)
|
||||||
|
try:
|
||||||
|
salt = bytes.fromhex(salt_hex)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
digest = hashlib.pbkdf2_hmac(
|
||||||
|
'sha256', pin.encode('utf-8'), salt, _PBKDF2_ITERATIONS,
|
||||||
|
)
|
||||||
|
return secrets.compare_digest(digest.hex(), expected_hex)
|
||||||
|
|
||||||
|
def set_tablet_pin(self, pin):
|
||||||
|
"""Set or change this user's tablet PIN. Requires sudo OR self.
|
||||||
|
|
||||||
|
Caller is responsible for verifying the OLD pin separately if a
|
||||||
|
hash already exists — this method just writes the new one.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
if not pin or not pin.isdigit() or len(pin) != 4:
|
||||||
|
raise UserError(_('Tablet PIN must be exactly 4 digits.'))
|
||||||
|
self.sudo().write({
|
||||||
|
'x_fc_tablet_pin_hash': self._hash_tablet_pin(pin),
|
||||||
|
'x_fc_tablet_pin_set_date': fields.Datetime.now(),
|
||||||
|
'x_fc_tablet_pin_failed_count': 0,
|
||||||
|
'x_fc_tablet_locked_until': False,
|
||||||
|
})
|
||||||
|
return True
|
||||||
|
|
||||||
|
def verify_tablet_pin(self, pin):
|
||||||
|
"""Return True if `pin` matches this user's stored hash."""
|
||||||
|
self.ensure_one()
|
||||||
|
if not pin:
|
||||||
|
return False
|
||||||
|
# sudo: even non-manager callers may need to verify their OWN PIN.
|
||||||
|
# The hash field has manager-only read; sudo bypasses that.
|
||||||
|
return self._verify_tablet_pin_hash(pin, self.sudo().x_fc_tablet_pin_hash)
|
||||||
|
|
||||||
|
def clear_tablet_pin(self):
|
||||||
|
"""Manager-side reset. Clears hash so target must set a new PIN.
|
||||||
|
Posts to chatter for audit.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
manager_name = self.env.user.name
|
||||||
|
self.sudo().write({
|
||||||
|
'x_fc_tablet_pin_hash': False,
|
||||||
|
'x_fc_tablet_pin_set_date': False,
|
||||||
|
'x_fc_tablet_pin_failed_count': 0,
|
||||||
|
'x_fc_tablet_locked_until': False,
|
||||||
|
})
|
||||||
|
self.message_post(
|
||||||
|
body=_('Tablet PIN reset by %s. User must set a new PIN '
|
||||||
|
'on next unlock attempt.') % manager_name,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def action_open_tablet_pin_setup(self):
|
||||||
|
"""Trigger the FpPinSetup OWL modal from the Preferences form.
|
||||||
|
The Phase 6.2 OWL component intercepts this action tag.
|
||||||
|
"""
|
||||||
|
self.ensure_one()
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'fp_tablet_pin_setup',
|
||||||
|
'name': 'Set Tablet PIN',
|
||||||
|
'target': 'new',
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import test_workspace_controller
|
from . import test_workspace_controller
|
||||||
from . import test_landing_kanban
|
from . import test_landing_kanban
|
||||||
|
from . import test_tablet_pin
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
# Copyright 2026 Nexa Systems Inc. — License OPL-1
|
||||||
|
"""Phase 6 — Tablet PIN gate tests."""
|
||||||
|
import json
|
||||||
|
|
||||||
|
from odoo.tests.common import HttpCase, TransactionCase, tagged
|
||||||
|
|
||||||
|
|
||||||
|
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
|
||||||
|
class TestTabletPinHash(TransactionCase):
|
||||||
|
"""P6.1.1 — model fields + hash helpers + set/verify/clear methods."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.user = self.env['res.users'].create({
|
||||||
|
'name': 'Pin Test',
|
||||||
|
'login': 'pintest@example.com',
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_set_pin_stores_salted_hash(self):
|
||||||
|
self.user.sudo().set_tablet_pin('1234')
|
||||||
|
stored = self.user.sudo().x_fc_tablet_pin_hash
|
||||||
|
self.assertTrue(stored)
|
||||||
|
self.assertIn('$', stored, 'hash must include salt separator')
|
||||||
|
# Hash is non-deterministic — setting same PIN twice gives different stored values
|
||||||
|
self.user.sudo().set_tablet_pin('1234')
|
||||||
|
self.assertNotEqual(stored, self.user.sudo().x_fc_tablet_pin_hash)
|
||||||
|
|
||||||
|
def test_verify_correct_pin(self):
|
||||||
|
self.user.sudo().set_tablet_pin('1234')
|
||||||
|
self.assertTrue(self.user.sudo().verify_tablet_pin('1234'))
|
||||||
|
|
||||||
|
def test_verify_wrong_pin(self):
|
||||||
|
self.user.sudo().set_tablet_pin('1234')
|
||||||
|
self.assertFalse(self.user.sudo().verify_tablet_pin('0000'))
|
||||||
|
|
||||||
|
def test_verify_pin_no_hash_set(self):
|
||||||
|
self.assertFalse(self.user.sudo().verify_tablet_pin('1234'))
|
||||||
|
|
||||||
|
def test_set_pin_rejects_invalid_format(self):
|
||||||
|
from odoo.exceptions import UserError
|
||||||
|
for bad in ('123', '12345', 'abcd', '', None):
|
||||||
|
with self.assertRaises(UserError):
|
||||||
|
self.user.sudo().set_tablet_pin(bad)
|
||||||
|
|
||||||
|
def test_clear_pin_wipes_hash_and_posts_chatter(self):
|
||||||
|
self.user.sudo().set_tablet_pin('1234')
|
||||||
|
self.user.clear_tablet_pin()
|
||||||
|
self.user.invalidate_recordset(['x_fc_tablet_pin_hash'])
|
||||||
|
self.assertFalse(self.user.sudo().x_fc_tablet_pin_hash)
|
||||||
|
self.assertFalse(self.user.sudo().x_fc_tablet_pin_set_date)
|
||||||
|
# Check chatter
|
||||||
|
last_msg = self.user.message_ids[:1]
|
||||||
|
self.assertIn('reset by', (last_msg.body or '').lower())
|
||||||
Reference in New Issue
Block a user