feat(fusion_plating_shopfloor): /fp/tablet/unlock with per-user lockout (P6.1.3)

Verifies PIN, resets failure counter on success, increments + locks out
on 5 consecutive failures (configurable via ir.config_parameter
fp.shopfloor.tablet_pin_fail_threshold + tablet_pin_fail_lockout_minutes,
both defaulting to 5).

Returns informative payloads:
  ok=true            current_tech_id, current_tech_name
  needs_setup=true   user has no PIN yet
  locked_until       lockout in effect (rejects even correct PIN)
  attempts_remaining failed but not yet locked

Logs INFO on success, WARNING on failure (with running counter +
locked flag).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-23 00:15:01 -04:00
parent 58d02598da
commit a594431eb6
2 changed files with 129 additions and 0 deletions

View File

@@ -75,3 +75,75 @@ class FpTabletController(http.Controller):
target.id, env.uid,
)
return {'ok': True}
# ======================================================================
# /fp/tablet/unlock — verify PIN + manage failure counter / lockout
# ======================================================================
@http.route('/fp/tablet/unlock', type='jsonrpc', auth='user')
def unlock(self, user_id, pin):
env = request.env
Users = env['res.users'].sudo() # need sudo to read hash field
target = Users.browse(int(user_id))
if not target.exists():
return {'ok': False, 'error': _('User not found.')}
# No PIN set yet — caller must set one first
if not target.x_fc_tablet_pin_hash:
return {
'ok': False,
'error': _('No PIN set. Set one in Preferences first.'),
'needs_setup': True,
}
# Currently locked out?
now = fields.Datetime.now()
if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now:
return {
'ok': False,
'error': _('Account locked. Try again in a few minutes.'),
'locked_until': target.x_fc_tablet_locked_until.isoformat(),
}
if target.verify_tablet_pin(pin):
# Reset failure state on success
target.write({
'x_fc_tablet_pin_failed_count': 0,
'x_fc_tablet_locked_until': False,
})
_logger.info(
"Tablet unlocked by uid %s (session uid %s)",
target.id, env.uid,
)
return {
'ok': True,
'current_tech_id': target.id,
'current_tech_name': target.name,
}
# Wrong PIN — increment and check threshold
new_count = (target.x_fc_tablet_pin_failed_count or 0) + 1
threshold = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_threshold', 5,
))
lockout_min = int(env['ir.config_parameter'].sudo().get_param(
'fp.shopfloor.tablet_pin_fail_lockout_minutes', 5,
))
vals = {'x_fc_tablet_pin_failed_count': new_count}
if new_count >= threshold:
vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min)
target.write(vals)
_logger.warning(
"Tablet PIN failure for uid %s (count=%d, locked=%s)",
target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')),
)
if vals.get('x_fc_tablet_locked_until'):
return {
'ok': False,
'error': _('Too many failed attempts. Locked for %d minutes.') % lockout_min,
'locked_until': vals['x_fc_tablet_locked_until'].isoformat(),
}
return {
'ok': False,
'error': _('Incorrect PIN.'),
'attempts_remaining': threshold - new_count,
}

View File

@@ -124,3 +124,60 @@ class TestTabletResetPinFor(HttpCase):
self.assertTrue(res['ok'])
self.target.invalidate_recordset(['x_fc_tablet_pin_hash'])
self.assertFalse(self.target.sudo().x_fc_tablet_pin_hash)
@tagged('-at_install', 'post_install', 'fp_shopfloor', 'fp_tablet_pin')
class TestTabletUnlock(HttpCase):
"""P6.1.3 — /fp/tablet/unlock endpoint + lockout."""
def setUp(self):
super().setUp()
self.authenticate("admin", "admin")
self.target = self.env['res.users'].create({
'name': 'Unlock Target', 'login': 'unlock@example.com',
})
self.target.sudo().set_tablet_pin('1234')
def _unlock(self, pin):
return _rpc(self, '/fp/tablet/unlock',
user_id=self.target.id, pin=pin)
def test_unlock_correct_pin(self):
res = self._unlock('1234')
self.assertTrue(res['ok'])
self.assertEqual(res['current_tech_id'], self.target.id)
self.assertEqual(res['current_tech_name'], 'Unlock Target')
def test_unlock_correct_pin_resets_fail_counter(self):
self._unlock('0000') # fail once
self._unlock('1234') # succeed
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 0)
def test_unlock_wrong_pin_increments_counter(self):
self._unlock('0000')
self.target.invalidate_recordset(['x_fc_tablet_pin_failed_count'])
self.assertEqual(self.target.sudo().x_fc_tablet_pin_failed_count, 1)
def test_lockout_after_5_fails(self):
for _ in range(5):
self._unlock('0000')
res = self._unlock('0000') # 6th
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
self.target.invalidate_recordset(['x_fc_tablet_locked_until'])
self.assertTrue(self.target.sudo().x_fc_tablet_locked_until)
def test_lockout_blocks_even_correct_pin(self):
for _ in range(5):
self._unlock('0000')
# Even the correct PIN now rejected
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertIn('locked', res['error'].lower())
def test_unlock_no_pin_set(self):
self.target.clear_tablet_pin()
res = self._unlock('1234')
self.assertFalse(res['ok'])
self.assertTrue(res.get('needs_setup'))