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:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'))
|
||||
|
||||
Reference in New Issue
Block a user