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) <noreply@anthropic.com>
This commit is contained in:
gsinghpal
2026-05-24 12:53:36 -04:00
parent 96e33834bd
commit 086ff512b6

View File

@@ -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
# ======================================================================