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:
@@ -211,6 +211,116 @@ class FpTabletController(http.Controller):
|
|||||||
'attempts_remaining': threshold - new_count,
|
'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
|
# /fp/tablet/tiles — lock-screen tile grid
|
||||||
# ======================================================================
|
# ======================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user