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_operator_queue
|
||||
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 -*-
|
||||
from . import test_workspace_controller
|
||||
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