From 086ff512b60b02fd9b1b4f8be36071e1ff4a3bd7 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 24 May 2026 12:53:36 -0400 Subject: [PATCH] feat(shopfloor): /fp/tablet/unlock_session mints real Odoo session PIN verify -> request.session.authenticate(type=fp_tablet_pin) -> new session sid, cookie swap, audit event written. Failed attempts also written to audit log (failed_unlock, failure_reason=wrong_pin or locked_out or no_pin_set or user_inactive). OLD /fp/tablet/unlock stays alive during the 1-week overlap window per spec Section 5. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../controllers/tablet_controller.py | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py index c97fb7c9..a7b446d1 100644 --- a/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py +++ b/fusion_plating/fusion_plating_shopfloor/controllers/tablet_controller.py @@ -211,6 +211,116 @@ class FpTabletController(http.Controller): 'attempts_remaining': threshold - new_count, } + # ====================================================================== + # /fp/tablet/unlock_session — verify PIN + mint REAL Odoo session as tech + # ====================================================================== + @http.route('/fp/tablet/unlock_session', type='jsonrpc', auth='user') + def unlock_session(self, user_id, pin): + """Phase 1 of the tablet PIN session redesign. + + Verifies the PIN, then mints a real Odoo session AS the tech + via the fp_tablet_pin custom auth manager. Browser cookie + swaps; subsequent requests carry the tech's session uid, so + create_uid / write_uid / chatter authorship attribute correctly + without any tablet_tech_id plumbing. + + Spec: docs/superpowers/specs/2026-05-24-tablet-pin-session-redesign-design.md + """ + from odoo.exceptions import AccessDenied + from ._tablet_session_audit import write_event, _sha256_session_sid + env = request.env + Users = env['res.users'].sudo() + target = Users.browse(int(user_id)) + if not target.exists(): + return {'ok': False, 'error': _('User not found.')} + + # No PIN set yet + if not target.x_fc_tablet_pin_hash: + write_event(env, + event_type='failed_unlock', + attempted_user_id=target.id, + failure_reason='no_pin_set') + return { + 'ok': False, + 'error': _('No PIN set. Set one in Preferences first.'), + 'needs_setup': True, + } + + # Inactive + if not target.active: + write_event(env, + event_type='failed_unlock', + attempted_user_id=target.id, + failure_reason='user_inactive') + return {'ok': False, 'error': _('User is inactive.')} + + # Currently locked out? + now = fields.Datetime.now() + if target.x_fc_tablet_locked_until and target.x_fc_tablet_locked_until > now: + write_event(env, + event_type='failed_unlock', + attempted_user_id=target.id, + failure_reason='locked_out') + return { + 'ok': False, + 'error': _('Account locked. Try again in a few minutes.'), + 'locked_until': target.x_fc_tablet_locked_until.isoformat(), + } + + # Attempt the real Odoo session swap via the custom auth manager. + # session.authenticate validates credentials through _check_credentials, + # issues a new sid, sets the cookie, returns the user dict. + try: + request.session.authenticate( + request.db, + {'type': 'fp_tablet_pin', + 'login': target.login, + 'pin': pin}, + ) + except AccessDenied: + # Wrong PIN — increment failure counter + 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} + failure_reason = 'wrong_pin' + if new_count >= threshold: + vals['x_fc_tablet_locked_until'] = now + timedelta(minutes=lockout_min) + failure_reason = 'locked_out' + target.write(vals) + write_event(env, + event_type='failed_unlock', + attempted_user_id=target.id, + failure_reason=failure_reason) + _logger.warning( + 'Tablet PIN failure for uid %s (count=%d, locked=%s)', + target.id, new_count, bool(vals.get('x_fc_tablet_locked_until')), + ) + return {'ok': False, 'error': _('Incorrect PIN.')} + + # Success path. session.authenticate already swapped the cookie. + sid = request.session.sid + target.write({ + 'x_fc_tablet_pin_failed_count': 0, + 'x_fc_tablet_locked_until': False, + }) + write_event(env, + event_type='unlock', + user_id=target.id, + session_id_hash=_sha256_session_sid(sid), + session_started_at=now) + _logger.info( + 'Tablet session minted for uid %s (sid %s..)', + target.id, sid[:8] if sid else '', + ) + return { + 'ok': True, + 'tech_id': target.id, + 'tech_name': target.name, + } + # ====================================================================== # /fp/tablet/tiles — lock-screen tile grid # ======================================================================