- Add "Mis cortes de caja" report for cashiers/counters with sales detail. - Cash register history scoped to own cuts for non-admin roles; new /register/<id>/sales endpoint. - Remove dashboard from cashier menu; add Reports to cashier/counter. - Service orders: assign mechanic, budget field, invoice flag, counter/cashier can add items/remissions, convert to remission. - Remission notes module (UI, CSS, courier, counter remissions). - Customer hard-delete and vehicle/customer linkage in workshop. - POS: always show search results, compact payment grid, credit validation, tier pricing (5%/10%), ticket with customer/folio. - Inventory: CSV template with sku_secondary, alias import. - Rached migration scripts and DB migrations. - Version-bump cached JS/CSS query strings. Excludes local Rached session tokens/captures (rached_*.json / rached_*.txt).
289 lines
9.9 KiB
Python
289 lines
9.9 KiB
Python
# /home/Autopartes/pos/blueprints/auth_bp.py
|
|
"""Auth blueprint: PIN login, JWT tokens, session management."""
|
|
|
|
import jwt
|
|
import bcrypt
|
|
import time
|
|
from datetime import datetime, timezone, timedelta
|
|
from flask import Blueprint, request, jsonify, g, make_response
|
|
from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES
|
|
from tenant_db import get_tenant_conn, get_master_conn
|
|
from middleware import require_auth
|
|
|
|
auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth')
|
|
|
|
# In-memory rate limiting (per device)
|
|
_pin_attempts = {} # device_id -> [(timestamp, success)]
|
|
|
|
|
|
def _check_rate_limit(device_id):
|
|
"""Check PIN rate limit. Returns (allowed, message)."""
|
|
now = time.time()
|
|
attempts = _pin_attempts.get(device_id, [])
|
|
|
|
# Clean old attempts (older than lockout period)
|
|
cutoff = now - (PIN_LOCKOUT_MINUTES * 60)
|
|
attempts = [a for a in attempts if a[0] > cutoff]
|
|
_pin_attempts[device_id] = attempts
|
|
|
|
# Check lockout
|
|
failed_count = sum(1 for a in attempts if not a[1])
|
|
if failed_count >= PIN_LOCKOUT_THRESHOLD:
|
|
return False, f'Dispositivo bloqueado. Intente en {PIN_LOCKOUT_MINUTES} minutos.'
|
|
|
|
# Check per-minute rate
|
|
one_min_ago = now - 60
|
|
recent = sum(1 for a in attempts if a[0] > one_min_ago and not a[1])
|
|
if recent >= PIN_MAX_ATTEMPTS_PER_MINUTE:
|
|
return False, 'Demasiados intentos. Espere un momento.'
|
|
|
|
return True, ''
|
|
|
|
|
|
def _record_attempt(device_id, success):
|
|
"""Record a PIN attempt."""
|
|
if device_id not in _pin_attempts:
|
|
_pin_attempts[device_id] = []
|
|
_pin_attempts[device_id].append((time.time(), success))
|
|
|
|
|
|
@auth_bp.route('/login', methods=['POST'])
|
|
def login_pin():
|
|
"""Login with tenant_id + PIN + device_id.
|
|
|
|
tenant_id can come from:
|
|
1. Subdomain (resolved by middleware_tenant into g.tenant_id)
|
|
2. POST body tenant_id field
|
|
3. Both (subdomain takes precedence)
|
|
"""
|
|
data = request.get_json() or {}
|
|
# Subdomain-resolved tenant takes priority over body param
|
|
tenant_id = getattr(g, 'tenant_id', None) or data.get('tenant_id')
|
|
pin = data.get('pin', '')
|
|
employee_id = data.get('employee_id')
|
|
device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown'))
|
|
# Optional: branch_id from the device for PIN search optimization
|
|
device_branch_id = data.get('branch_id')
|
|
|
|
if not tenant_id or not pin:
|
|
return jsonify({'error': 'tenant_id and pin required'}), 400
|
|
|
|
# Rate limit check
|
|
allowed, msg = _check_rate_limit(device_id)
|
|
if not allowed:
|
|
return jsonify({'error': msg}), 429
|
|
|
|
try:
|
|
conn = get_tenant_conn(tenant_id)
|
|
except ValueError:
|
|
return jsonify({'error': 'Tenant not found'}), 404
|
|
|
|
cur = conn.cursor()
|
|
|
|
# PERFORMANCE NOTE: This PIN check is O(n) over active employees because PINs are
|
|
# hashed and cannot be looked up directly. For most tenants (<100 employees) this is
|
|
# fine. If a tenant has hundreds of employees, consider:
|
|
# 1. Adding a PIN prefix index (first 2 digits stored as a non-secret hint column)
|
|
# 2. Caching active employee count and alerting if >200
|
|
#
|
|
# Short-circuit optimization: if the device is bound to a branch (branch_id known),
|
|
# query that branch's employees first to reduce the bcrypt comparison space.
|
|
|
|
matched_employee = None
|
|
|
|
if not employee_id:
|
|
_record_attempt(device_id, False)
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'error': 'Empleado no seleccionado'}), 400
|
|
|
|
# Verify the PIN only for the selected employee.
|
|
cur.execute("""
|
|
SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct
|
|
FROM employees e
|
|
WHERE e.id = %s AND e.is_active = true AND e.pin IS NOT NULL
|
|
""", (employee_id,))
|
|
emp = cur.fetchone()
|
|
if emp:
|
|
emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp
|
|
if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()):
|
|
matched_employee = {
|
|
'id': emp_id, 'name': emp_name, 'role': emp_role,
|
|
'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0
|
|
}
|
|
|
|
if not matched_employee:
|
|
_record_attempt(device_id, False)
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'error': 'PIN incorrecto'}), 401
|
|
|
|
_record_attempt(device_id, True)
|
|
|
|
# Get permissions
|
|
cur.execute(
|
|
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
|
(matched_employee['id'],)
|
|
)
|
|
permissions = [r[0] for r in cur.fetchall()]
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
# Generate JWT
|
|
payload = {
|
|
'tenant_id': tenant_id,
|
|
'employee_id': matched_employee['id'],
|
|
'name': matched_employee['name'],
|
|
'role': matched_employee['role'],
|
|
'branch_id': matched_employee['branch_id'],
|
|
'max_discount_pct': matched_employee['max_discount_pct'],
|
|
'permissions': permissions,
|
|
'device_id': device_id,
|
|
'type': 'pos_access',
|
|
'exp': datetime.now(timezone.utc) + timedelta(seconds=JWT_ACCESS_EXPIRES),
|
|
'iat': datetime.now(timezone.utc),
|
|
}
|
|
token = jwt.encode(payload, JWT_SECRET, algorithm='HS256')
|
|
|
|
response = make_response(jsonify({
|
|
'token': token,
|
|
'employee': matched_employee,
|
|
'permissions': permissions
|
|
}))
|
|
# Cookie used by server-side route guards; JS can also read it for quick checks.
|
|
response.set_cookie(
|
|
'pos_role', matched_employee['role'],
|
|
path='/pos', samesite='Lax', httponly=False
|
|
)
|
|
return response
|
|
|
|
|
|
@auth_bp.route('/refresh', methods=['POST'])
|
|
@require_auth()
|
|
def refresh_token():
|
|
"""Reissue the JWT with the employee's current permissions from the DB.
|
|
|
|
This lets permission changes take effect without forcing a full re-login.
|
|
The original expiration time is preserved.
|
|
"""
|
|
auth_header = request.headers.get('Authorization', '')
|
|
try:
|
|
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
|
except jwt.InvalidTokenError:
|
|
return jsonify({'error': 'Invalid token'}), 401
|
|
|
|
tenant_id = payload.get('tenant_id')
|
|
employee_id = payload.get('employee_id')
|
|
conn = get_tenant_conn(tenant_id)
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"""
|
|
SELECT e.id, e.name, e.role, e.branch_id, e.max_discount_pct
|
|
FROM employees e
|
|
WHERE e.id = %s AND e.is_active = true
|
|
""",
|
|
(employee_id,)
|
|
)
|
|
emp = cur.fetchone()
|
|
if not emp:
|
|
cur.close(); conn.close()
|
|
return jsonify({'error': 'Employee not found or inactive'}), 404
|
|
|
|
cur.execute(
|
|
"SELECT permission FROM employee_permissions WHERE employee_id = %s",
|
|
(emp[0],)
|
|
)
|
|
permissions = [r[0] for r in cur.fetchall()]
|
|
cur.close(); conn.close()
|
|
|
|
new_payload = {
|
|
'tenant_id': tenant_id,
|
|
'employee_id': emp[0],
|
|
'name': emp[1],
|
|
'role': emp[2],
|
|
'branch_id': emp[3],
|
|
'max_discount_pct': float(emp[4]) if emp[4] else 0,
|
|
'permissions': permissions,
|
|
'device_id': payload.get('device_id', 'unknown'),
|
|
'type': 'pos_access',
|
|
'exp': payload.get('exp'),
|
|
'iat': datetime.now(timezone.utc),
|
|
}
|
|
token = jwt.encode(new_payload, JWT_SECRET, algorithm='HS256')
|
|
return jsonify({
|
|
'token': token,
|
|
'employee': {
|
|
'id': emp[0], 'name': emp[1], 'role': emp[2],
|
|
'branch_id': emp[3], 'max_discount_pct': new_payload['max_discount_pct']
|
|
},
|
|
'permissions': permissions
|
|
})
|
|
|
|
|
|
@auth_bp.route('/employees/<int:tenant_id>', methods=['GET'])
|
|
@auth_bp.route('/employees', methods=['GET'])
|
|
def list_login_employees(tenant_id=None):
|
|
"""Public endpoint: list employees for the login screen (names + roles only, no sensitive data).
|
|
|
|
tenant_id comes from URL path, subdomain, or ?tenant= param.
|
|
"""
|
|
# Resolve tenant_id: URL path > subdomain > query param
|
|
tid = tenant_id or getattr(g, 'tenant_id', None)
|
|
if not tid:
|
|
try:
|
|
tid = int(request.args.get('tenant', 0))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
if not tid:
|
|
return jsonify({'error': 'Tenant not specified'}), 400
|
|
|
|
try:
|
|
conn = get_tenant_conn(tid)
|
|
except ValueError:
|
|
return jsonify({'error': 'Tenant not found'}), 404
|
|
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT id, name, role FROM employees
|
|
WHERE is_active = true AND pin IS NOT NULL
|
|
ORDER BY name
|
|
""")
|
|
employees = []
|
|
for row in cur.fetchall():
|
|
name = row[1]
|
|
parts = name.split()
|
|
initials = ''.join([p[0].upper() for p in parts[:2]]) if parts else '?'
|
|
role_labels = {'owner': 'Dueño', 'admin': 'Administrador', 'cashier': 'Cajero', 'counter': 'Mostrador', 'warehouse': 'Almacén', 'accountant': 'Contador', 'workshop': 'Taller', 'mechanic': 'Mecánico'}
|
|
employees.append({
|
|
'id': row[0],
|
|
'name': name,
|
|
'initials': initials,
|
|
'role': row[2],
|
|
'role_label': role_labels.get(row[2], row[2])
|
|
})
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'data': employees})
|
|
|
|
|
|
@auth_bp.route('/me', methods=['GET'])
|
|
def auth_me():
|
|
"""Get current employee info from token."""
|
|
auth_header = request.headers.get('Authorization', '')
|
|
if not auth_header.startswith('Bearer '):
|
|
return jsonify({'error': 'Token required'}), 401
|
|
|
|
try:
|
|
payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256'])
|
|
return jsonify({
|
|
'employee_id': payload['employee_id'],
|
|
'name': payload['name'],
|
|
'role': payload['role'],
|
|
'tenant_id': payload['tenant_id'],
|
|
'branch_id': payload.get('branch_id'),
|
|
'permissions': payload.get('permissions', [])
|
|
})
|
|
except jwt.InvalidTokenError:
|
|
return jsonify({'error': 'Invalid token'}), 401
|